diff --git a/data-machine.php b/data-machine.php index 212d0167..543cc03e 100644 --- a/data-machine.php +++ b/data-machine.php @@ -112,6 +112,7 @@ function () { \DataMachine\Api\Providers::register(); \DataMachine\Api\Analytics::register(); \DataMachine\Api\InternalLinks::register(); + \DataMachine\Api\Email::register(); // Load abilities require_once __DIR__ . '/inc/Abilities/AuthAbilities.php'; @@ -158,12 +159,15 @@ function () { require_once __DIR__ . '/inc/Abilities/Content/ReplacePostBlocksAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/GitHubAbilities.php'; require_once __DIR__ . '/inc/Abilities/Fetch/FetchFilesAbility.php'; + require_once __DIR__ . '/inc/Abilities/Email/EmailAbilities.php'; + require_once __DIR__ . '/inc/Abilities/Fetch/FetchEmailAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/FetchRssAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressApiAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/FetchWordPressMediaAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/GetWordPressPostAbility.php'; require_once __DIR__ . '/inc/Abilities/Fetch/QueryWordPressPostsAbility.php'; require_once __DIR__ . '/inc/Abilities/Publish/PublishWordPressAbility.php'; + require_once __DIR__ . '/inc/Abilities/Publish/SendEmailAbility.php'; require_once __DIR__ . '/inc/Abilities/Update/UpdateWordPressAbility.php'; // Defer ability instantiation to init so translations are loaded. add_action( 'init', function () { @@ -208,12 +212,15 @@ function () { new \DataMachine\Abilities\Content\ReplacePostBlocksAbility(); new \DataMachine\Abilities\Fetch\GitHubAbilities(); new \DataMachine\Abilities\Fetch\FetchFilesAbility(); + new \DataMachine\Abilities\Email\EmailAbilities(); + new \DataMachine\Abilities\Fetch\FetchEmailAbility(); new \DataMachine\Abilities\Fetch\FetchRssAbility(); new \DataMachine\Abilities\Fetch\FetchWordPressApiAbility(); new \DataMachine\Abilities\Fetch\FetchWordPressMediaAbility(); new \DataMachine\Abilities\Fetch\GetWordPressPostAbility(); new \DataMachine\Abilities\Fetch\QueryWordPressPostsAbility(); new \DataMachine\Abilities\Publish\PublishWordPressAbility(); + new \DataMachine\Abilities\Publish\SendEmailAbility(); new \DataMachine\Abilities\Update\UpdateWordPressAbility(); } ); } @@ -274,12 +281,14 @@ function datamachine_load_step_types() { function datamachine_load_handlers() { // Publish Handlers (core only - social handlers moved to data-machine-socials plugin) new \DataMachine\Core\Steps\Publish\Handlers\WordPress\WordPress(); + new \DataMachine\Core\Steps\Publish\Handlers\Email\Email(); // Fetch Handlers new \DataMachine\Core\Steps\Fetch\Handlers\WordPress\WordPress(); new \DataMachine\Core\Steps\Fetch\Handlers\WordPressAPI\WordPressAPI(); new \DataMachine\Core\Steps\Fetch\Handlers\WordPressMedia\WordPressMedia(); new \DataMachine\Core\Steps\Fetch\Handlers\Rss\Rss(); + new \DataMachine\Core\Steps\Fetch\Handlers\Email\Email(); new \DataMachine\Core\Steps\Fetch\Handlers\Files\Files(); new \DataMachine\Core\Steps\Fetch\Handlers\GitHub\GitHub(); new \DataMachine\Core\Steps\Fetch\Handlers\Workspace\Workspace(); diff --git a/inc/Abilities/Email/EmailAbilities.php b/inc/Abilities/Email/EmailAbilities.php new file mode 100644 index 00000000..3cb952d8 --- /dev/null +++ b/inc/Abilities/Email/EmailAbilities.php @@ -0,0 +1,1290 @@ +registerAbilities(); + self::$registered = true; + } + + private function registerAbilities(): void { + $register_callback = function () { + // Reply to an email. + wp_register_ability( + 'datamachine/email-reply', + array( + 'label' => __( 'Reply to Email', 'data-machine' ), + 'description' => __( 'Send a reply to an email, maintaining thread headers', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'to', 'subject', 'body', 'in_reply_to' ), + 'properties' => array( + 'to' => array( + 'type' => 'string', + 'description' => __( 'Recipient email address', 'data-machine' ), + ), + 'subject' => array( + 'type' => 'string', + 'description' => __( 'Reply subject (typically Re: original subject)', 'data-machine' ), + ), + 'body' => array( + 'type' => 'string', + 'description' => __( 'Reply body content', 'data-machine' ), + ), + 'in_reply_to' => array( + 'type' => 'string', + 'description' => __( 'Message-ID of the email being replied to', 'data-machine' ), + ), + 'references' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'References header chain for threading', 'data-machine' ), + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'executeReply' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Delete an email via IMAP. + wp_register_ability( + 'datamachine/email-delete', + array( + 'label' => __( 'Delete Email', 'data-machine' ), + 'description' => __( 'Delete (expunge) an email by UID from the IMAP server', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to delete', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeDelete' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Move an email to a different folder. + wp_register_ability( + 'datamachine/email-move', + array( + 'label' => __( 'Move Email', 'data-machine' ), + 'description' => __( 'Move an email to a different IMAP folder', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid', 'destination' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to move', 'data-machine' ), + ), + 'destination' => array( + 'type' => 'string', + 'description' => __( 'Target folder (e.g., Archive, Trash, [Gmail]/All Mail)', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeMove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Flag/unflag an email. + wp_register_ability( + 'datamachine/email-flag', + array( + 'label' => __( 'Flag Email', 'data-machine' ), + 'description' => __( 'Set or clear IMAP flags on an email (Seen, Flagged, etc.)', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid', 'flag' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID', 'data-machine' ), + ), + 'flag' => array( + 'type' => 'string', + 'description' => __( 'IMAP flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + 'description' => __( 'set or clear the flag', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeFlag' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch move: search → move all matches. + wp_register_ability( + 'datamachine/email-batch-move', + array( + 'label' => __( 'Batch Move Emails', 'data-machine' ), + 'description' => __( 'Move all emails matching a search to a destination folder', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search', 'destination' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria (e.g., FROM "github.com")', 'data-machine' ), + ), + 'destination' => array( + 'type' => 'string', + 'description' => __( 'Target folder (e.g., [Gmail]/GitHub, Archive)', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + 'description' => __( 'Maximum messages to move (safety limit)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'moved_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchMove' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch flag: search → flag/unflag all matches. + wp_register_ability( + 'datamachine/email-batch-flag', + array( + 'label' => __( 'Batch Flag Emails', 'data-machine' ), + 'description' => __( 'Set or clear a flag on all emails matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search', 'flag' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'flag' => array( + 'type' => 'string', + 'description' => __( 'Flag: Seen, Flagged, Answered, Deleted, Draft', 'data-machine' ), + ), + 'action' => array( + 'type' => 'string', + 'default' => 'set', + 'description' => __( 'set or clear', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 500, + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'flagged_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchFlag' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch delete: search → delete all matches. + wp_register_ability( + 'datamachine/email-batch-delete', + array( + 'label' => __( 'Batch Delete Emails', 'data-machine' ), + 'description' => __( 'Delete all emails matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 100, + 'description' => __( 'Maximum messages to delete (safety limit, lower default)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'deleted_count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchDelete' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Unsubscribe from a mailing list. + wp_register_ability( + 'datamachine/email-unsubscribe', + array( + 'label' => __( 'Unsubscribe from Email', 'data-machine' ), + 'description' => __( 'Unsubscribe from a mailing list using List-Unsubscribe headers', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'uid' ), + 'properties' => array( + 'uid' => array( + 'type' => 'integer', + 'description' => __( 'Message UID to unsubscribe from', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'method' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeUnsubscribe' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Batch unsubscribe from all matching senders. + wp_register_ability( + 'datamachine/email-batch-unsubscribe', + array( + 'label' => __( 'Batch Unsubscribe', 'data-machine' ), + 'description' => __( 'Unsubscribe from all mailing lists matching a search', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'search' ), + 'properties' => array( + 'search' => array( + 'type' => 'string', + 'description' => __( 'IMAP search criteria', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + ), + 'max' => array( + 'type' => 'integer', + 'default' => 20, + 'description' => __( 'Max unique senders to unsubscribe from (deduped by sender)', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'results' => array( 'type' => 'array' ), + 'unsubscribed' => array( 'type' => 'integer' ), + 'failed' => array( 'type' => 'integer' ), + 'no_header' => array( 'type' => 'integer' ), + ), + ), + 'execute_callback' => array( $this, 'executeBatchUnsubscribe' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + + // Test IMAP connection. + wp_register_ability( + 'datamachine/email-test-connection', + array( + 'label' => __( 'Test Email Connection', 'data-machine' ), + 'description' => __( 'Test IMAP connection with stored credentials', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'properties' => new \stdClass(), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'mailbox_info' => array( 'type' => 'object' ), + 'error' => array( 'type' => 'string' ), + ), + ), + 'execute_callback' => array( $this, 'executeTestConnection' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Reply to an email with threading headers. + */ + public function executeReply( array $input ): array { + $headers = array(); + + $content_type = $input['content_type'] ?? 'text/html'; + $headers[] = "Content-Type: {$content_type}; charset=UTF-8"; + + // Threading headers. + if ( ! empty( $input['in_reply_to'] ) ) { + $headers[] = 'In-Reply-To: ' . $input['in_reply_to']; + } + + $references = $input['references'] ?? ''; + if ( ! empty( $input['in_reply_to'] ) ) { + $references = trim( $references . ' ' . $input['in_reply_to'] ); + } + if ( ! empty( $references ) ) { + $headers[] = 'References: ' . $references; + } + + if ( ! empty( $input['cc'] ) ) { + $cc_list = array_map( 'trim', explode( ',', $input['cc'] ) ); + foreach ( $cc_list as $cc ) { + if ( is_email( $cc ) ) { + $headers[] = 'Cc: ' . $cc; + } + } + } + + $to = array_map( 'trim', explode( ',', $input['to'] ) ); + $to = array_filter( $to, 'is_email' ); + + if ( empty( $to ) ) { + return array( + 'success' => false, + 'error' => 'No valid recipient address', + ); + } + + $sent = wp_mail( $to, $input['subject'], $input['body'], $headers ); + + if ( $sent ) { + return array( + 'success' => true, + 'message' => 'Reply sent to ' . implode( ', ', $to ), + 'logs' => array(), + ); + } + + global $phpmailer; + $error = 'wp_mail() returned false'; + if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { + $error = $phpmailer->ErrorInfo ?: $error; + } + + return array( + 'success' => false, + 'error' => $error, + ); + } + + /** + * Delete an email from the IMAP server. + */ + public function executeDelete( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + imap_delete( $connection, (string) $uid, FT_UID ); + imap_expunge( $connection ); + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Message UID %d deleted', $uid ), + ); + } + + /** + * Move an email to a different folder. + */ + public function executeMove( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + $destination = $input['destination']; + + $moved = imap_mail_move( $connection, (string) $uid, $destination, CP_UID ); + if ( ! $moved ) { + $error = imap_last_error(); + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Move failed: ' . $error, + ); + } + + imap_expunge( $connection ); + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Message UID %d moved to %s', $uid, $destination ), + ); + } + + /** + * Set or clear a flag on an email. + */ + public function executeFlag( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); + + $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); + if ( ! in_array( $flag, $valid_flags, true ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Invalid flag. Valid flags: Seen, Flagged, Answered, Deleted, Draft', + ); + } + + $action = $input['action'] ?? 'set'; + if ( 'clear' === $action ) { + $result = imap_clearflag_full( $connection, (string) $uid, $flag, ST_UID ); + } else { + $result = imap_setflag_full( $connection, (string) $uid, $flag, ST_UID ); + } + + imap_close( $connection ); + + if ( ! $result ) { + return array( + 'success' => false, + 'error' => 'Failed to ' . $action . ' flag ' . $flag, + ); + } + + return array( + 'success' => true, + 'message' => sprintf( 'Flag %s %s on UID %d', $flag, $action === 'clear' ? 'cleared' : 'set', $uid ), + ); + } + + /** + * Test the IMAP connection with stored credentials. + */ + public function executeTestConnection( array $input ): array { + if ( ! function_exists( 'imap_open' ) ) { + return array( + 'success' => false, + 'error' => 'PHP IMAP extension is not installed', + ); + } + + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + return array( + 'success' => false, + 'error' => 'IMAP credentials not configured', + ); + } + + $mailbox = $this->buildMailboxString( + $auth->getHost(), + $auth->getPort(), + $auth->getEncryption(), + 'INBOX' + ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); + + if ( false === $connection ) { + return array( + 'success' => false, + 'error' => 'Connection failed: ' . imap_last_error(), + ); + } + + $check = imap_check( $connection ); + $info = array( + 'mailbox' => $check->Mailbox ?? '', + 'messages' => $check->Nmsgs ?? 0, + 'recent' => $check->Recent ?? 0, + 'connected' => true, + ); + + // List available folders. + $folders = imap_list( $connection, $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ), '*' ); + $folder_list = array(); + if ( is_array( $folders ) ) { + $prefix = $this->buildMailboxString( $auth->getHost(), $auth->getPort(), $auth->getEncryption(), '' ); + foreach ( $folders as $folder ) { + $folder_list[] = str_replace( $prefix, '', imap_utf7_decode( $folder ) ); + } + } + $info['folders'] = $folder_list; + + imap_close( $connection ); + + return array( + 'success' => true, + 'message' => sprintf( 'Connected to %s — %d messages in INBOX', $auth->getHost(), $info['messages'] ), + 'mailbox_info' => $info, + ); + } + + /** + * Unsubscribe from a mailing list using List-Unsubscribe headers. + * + * Priority order: + * 1. List-Unsubscribe-Post + URL → HTTP POST (RFC 8058 one-click) + * 2. URL without Post header → HTTP POST attempt, fall back to GET + * 3. mailto: → send email via wp_mail() + */ + public function executeUnsubscribe( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $uid = (int) $input['uid']; + + // Fetch raw headers. + $raw_headers = imap_fetchheader( $connection, $uid, FT_UID ); + if ( empty( $raw_headers ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Could not fetch message headers', + ); + } + + $parsed = $this->parseUnsubscribeHeaders( $raw_headers ); + imap_close( $connection ); + + if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { + return array( + 'success' => false, + 'error' => 'No List-Unsubscribe header found in this message', + ); + } + + // Try One-Click POST first (RFC 8058). + if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { + $url = $parsed['urls'][0]; + $result = $this->executeOneClickUnsubscribe( $url ); + if ( $result['success'] ) { + return $result; + } + // Fall through to other methods if POST failed. + } + + // Try URL GET/POST. + if ( ! empty( $parsed['urls'] ) ) { + foreach ( $parsed['urls'] as $url ) { + $result = $this->executeUrlUnsubscribe( $url ); + if ( $result['success'] ) { + return $result; + } + } + } + + // Try mailto. + if ( ! empty( $parsed['mailto'] ) ) { + $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); + if ( $result['success'] ) { + return $result; + } + } + + return array( + 'success' => false, + 'error' => 'All unsubscribe methods failed', + ); + } + + /** + * Batch unsubscribe: search → unsubscribe from unique senders. + * + * Deduplicates by sender — if you have 100 emails from linkedin.com, + * it only unsubscribes once using the most recent message's headers. + */ + public function executeBatchUnsubscribe( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $max = (int) ( $input['max'] ?? 20 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'results' => array(), + 'unsubscribed' => 0, + 'failed' => 0, + 'no_header' => 0, + ); + } + + // Most recent first — we want the newest unsubscribe link per sender. + $uids = array_reverse( $uids ); + + // Deduplicate by sender — one unsubscribe per unique From address. + $seen_senders = array(); + $to_process = array(); + + foreach ( $uids as $uid ) { + if ( count( $to_process ) >= $max ) { + break; + } + + $msgno = imap_msgno( $connection, $uid ); + if ( 0 === $msgno ) { + continue; + } + + $header = imap_headerinfo( $connection, $msgno ); + if ( false === $header || empty( $header->from ) ) { + continue; + } + + $from = $header->from[0]; + $sender = $from->mailbox . '@' . $from->host; + + if ( isset( $seen_senders[ $sender ] ) ) { + continue; + } + + $seen_senders[ $sender ] = true; + + // Fetch unsubscribe headers. + $raw = imap_fetchheader( $connection, $uid, FT_UID ); + $parsed = $this->parseUnsubscribeHeaders( $raw ); + + $to_process[] = array( + 'uid' => $uid, + 'sender' => $sender, + 'parsed' => $parsed, + ); + } + + imap_close( $connection ); + + // Now execute unsubscribes (connection closed — these are HTTP/mailto). + $results = array(); + $unsubscribed = 0; + $failed = 0; + $no_header = 0; + + foreach ( $to_process as $item ) { + $parsed = $item['parsed']; + + if ( empty( $parsed['urls'] ) && empty( $parsed['mailto'] ) ) { + $results[] = array( + 'sender' => $item['sender'], + 'success' => false, + 'reason' => 'no List-Unsubscribe header', + ); + ++$no_header; + continue; + } + + $result = null; + + // Try One-Click POST first. + if ( $parsed['has_one_click'] && ! empty( $parsed['urls'] ) ) { + $result = $this->executeOneClickUnsubscribe( $parsed['urls'][0] ); + } + + // Fall back to URL. + if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['urls'] ) ) { + $result = $this->executeUrlUnsubscribe( $parsed['urls'][0] ); + } + + // Fall back to mailto. + if ( ( ! $result || ! $result['success'] ) && ! empty( $parsed['mailto'] ) ) { + $result = $this->executeMailtoUnsubscribe( $parsed['mailto'] ); + } + + if ( $result && $result['success'] ) { + $results[] = array( + 'sender' => $item['sender'], + 'success' => true, + 'method' => $result['method'] ?? 'unknown', + ); + ++$unsubscribed; + } else { + $results[] = array( + 'sender' => $item['sender'], + 'success' => false, + 'reason' => $result['error'] ?? 'all methods failed', + ); + ++$failed; + } + } + + return array( + 'success' => true, + 'message' => sprintf( + 'Processed %d senders: %d unsubscribed, %d failed, %d had no header', + count( $to_process ), + $unsubscribed, + $failed, + $no_header + ), + 'results' => $results, + 'unsubscribed' => $unsubscribed, + 'failed' => $failed, + 'no_header' => $no_header, + ); + } + + /** + * Parse List-Unsubscribe and List-Unsubscribe-Post headers. + * + * @param string $raw_headers Raw email headers. + * @return array Parsed data with urls, mailto, has_one_click. + */ + private function parseUnsubscribeHeaders( string $raw_headers ): array { + $unsub_header = ''; + $post_header = ''; + $collecting = ''; + + foreach ( explode( "\n", $raw_headers ) as $line ) { + // Continuation line. + if ( $collecting && preg_match( '/^\s/', $line ) ) { + if ( 'unsub' === $collecting ) { + $unsub_header .= ' ' . trim( $line ); + } + if ( 'post' === $collecting ) { + $post_header .= ' ' . trim( $line ); + } + continue; + } + $collecting = ''; + + if ( stripos( $line, 'List-Unsubscribe:' ) === 0 ) { + $unsub_header = trim( substr( $line, 17 ) ); + $collecting = 'unsub'; + } + if ( stripos( $line, 'List-Unsubscribe-Post:' ) === 0 ) { + $post_header = trim( substr( $line, 22 ) ); + $collecting = 'post'; + } + } + + // Decode MIME-encoded headers. + if ( ! empty( $unsub_header ) ) { + $unsub_header = imap_utf8( $unsub_header ); + } + + // Extract URLs and mailto from angle brackets. + $urls = array(); + $mailto = ''; + + if ( preg_match_all( '/<([^>]+)>/', $unsub_header, $matches ) ) { + foreach ( $matches[1] as $value ) { + if ( strpos( $value, 'mailto:' ) === 0 ) { + $mailto = $value; + } elseif ( filter_var( $value, FILTER_VALIDATE_URL ) ) { + $urls[] = $value; + } + } + } + + $has_one_click = stripos( $post_header, 'List-Unsubscribe=One-Click' ) !== false; + + return array( + 'urls' => $urls, + 'mailto' => $mailto, + 'has_one_click' => $has_one_click, + 'raw' => $unsub_header, + ); + } + + /** + * RFC 8058 One-Click unsubscribe via HTTP POST. + */ + private function executeOneClickUnsubscribe( string $url ): array { + $response = wp_remote_post( $url, array( + 'body' => 'List-Unsubscribe=One-Click', + 'headers' => array( 'Content-Type' => 'application/x-www-form-urlencoded' ), + 'timeout' => 15, + ) ); + + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'error' => 'POST failed: ' . $response->get_error_message(), + ); + } + + $code = wp_remote_retrieve_response_code( $response ); + + // 2xx = success. Some return 200, others 204. + if ( $code >= 200 && $code < 300 ) { + return array( + 'success' => true, + 'message' => 'Unsubscribed via One-Click POST (HTTP ' . $code . ')', + 'method' => 'one-click-post', + ); + } + + return array( + 'success' => false, + 'error' => 'One-Click POST returned HTTP ' . $code, + ); + } + + /** + * Unsubscribe via URL (GET request). + */ + private function executeUrlUnsubscribe( string $url ): array { + $response = wp_remote_get( $url, array( + 'timeout' => 15, + 'sslverify' => true, + ) ); + + if ( is_wp_error( $response ) ) { + return array( + 'success' => false, + 'error' => 'GET failed: ' . $response->get_error_message(), + ); + } + + $code = wp_remote_retrieve_response_code( $response ); + + if ( $code >= 200 && $code < 400 ) { + return array( + 'success' => true, + 'message' => 'Unsubscribe request sent (HTTP ' . $code . ')', + 'method' => 'url-get', + ); + } + + return array( + 'success' => false, + 'error' => 'URL request returned HTTP ' . $code, + ); + } + + /** + * Unsubscribe via mailto: — send an email. + */ + private function executeMailtoUnsubscribe( string $mailto_uri ): array { + // Parse mailto:address?subject=... + $parts = wp_parse_url( $mailto_uri ); + $address = str_replace( 'mailto:', '', $parts['path'] ?? '' ); + + if ( empty( $address ) || ! is_email( $address ) ) { + return array( + 'success' => false, + 'error' => 'Invalid mailto address: ' . $mailto_uri, + ); + } + + $subject = ''; + if ( ! empty( $parts['query'] ) ) { + parse_str( $parts['query'], $query ); + $subject = $query['subject'] ?? 'unsubscribe'; + } + if ( empty( $subject ) ) { + $subject = 'unsubscribe'; + } + + $sent = wp_mail( $address, $subject, 'unsubscribe' ); + + if ( $sent ) { + return array( + 'success' => true, + 'message' => 'Unsubscribe email sent to ' . $address, + 'method' => 'mailto', + ); + } + + return array( + 'success' => false, + 'error' => 'Failed to send unsubscribe email', + ); + } + + /** + * Batch move: search → move all matches to destination. + */ + public function executeBatchMove( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $destination = $input['destination']; + $max = (int) ( $input['max'] ?? 500 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'moved_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_move = array_slice( $uids, 0, $max ); + $moved = 0; + + // Use comma-separated UID range for batch operation (much faster than per-message). + $uid_set = implode( ',', $to_move ); + $result = imap_mail_move( $connection, $uid_set, $destination, CP_UID ); + + if ( $result ) { + $moved = count( $to_move ); + imap_expunge( $connection ); + } + + imap_close( $connection ); + + $message = sprintf( 'Moved %d messages to %s', $moved, $destination ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'moved_count' => $moved, + 'total_matches' => $total, + ); + } + + /** + * Batch flag: search → set/clear flag on all matches. + */ + public function executeBatchFlag( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $flag = '\\' . ucfirst( strtolower( $input['flag'] ) ); + $action = $input['action'] ?? 'set'; + $max = (int) ( $input['max'] ?? 500 ); + + $valid_flags = array( '\\Seen', '\\Flagged', '\\Answered', '\\Deleted', '\\Draft' ); + if ( ! in_array( $flag, $valid_flags, true ) ) { + imap_close( $connection ); + return array( + 'success' => false, + 'error' => 'Invalid flag. Valid: Seen, Flagged, Answered, Deleted, Draft', + ); + } + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'flagged_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_flag = array_slice( $uids, 0, $max ); + $uid_set = implode( ',', $to_flag ); + + if ( 'clear' === $action ) { + imap_clearflag_full( $connection, $uid_set, $flag, ST_UID ); + } else { + imap_setflag_full( $connection, $uid_set, $flag, ST_UID ); + } + + imap_close( $connection ); + + $verb = 'clear' === $action ? 'cleared' : 'set'; + $message = sprintf( '%s %s on %d messages', ucfirst( $verb ), $flag, count( $to_flag ) ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'flagged_count' => count( $to_flag ), + 'total_matches' => $total, + ); + } + + /** + * Batch delete: search → delete all matches. + */ + public function executeBatchDelete( array $input ): array { + $connection = $this->connect( $input['folder'] ?? 'INBOX' ); + if ( is_array( $connection ) && ! ( $connection['success'] ?? true ) ) { + return $connection; + } + + $search = $input['search']; + $max = (int) ( $input['max'] ?? 100 ); + + $uids = imap_search( $connection, $search, SE_UID ); + if ( false === $uids || empty( $uids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'message' => 'No messages matching search criteria', + 'deleted_count' => 0, + 'total_matches' => 0, + ); + } + + $total = count( $uids ); + $to_delete = array_slice( $uids, 0, $max ); + $uid_set = implode( ',', $to_delete ); + + imap_delete( $connection, $uid_set, FT_UID ); + imap_expunge( $connection ); + imap_close( $connection ); + + $message = sprintf( 'Deleted %d messages', count( $to_delete ) ); + if ( $total > $max ) { + $message .= sprintf( ' (%d more remain — run again to continue)', $total - $max ); + } + + return array( + 'success' => true, + 'message' => $message, + 'deleted_count' => count( $to_delete ), + 'total_matches' => $total, + ); + } + + /** + * Open an IMAP connection using stored credentials. + * + * @param string $folder Mail folder. + * @return resource|array IMAP connection or error array. + */ + private function connect( string $folder = 'INBOX' ) { + if ( ! function_exists( 'imap_open' ) ) { + return array( + 'success' => false, + 'error' => 'PHP IMAP extension is not installed', + ); + } + + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + return array( + 'success' => false, + 'error' => 'IMAP credentials not configured', + ); + } + + $mailbox = $this->buildMailboxString( + $auth->getHost(), + $auth->getPort(), + $auth->getEncryption(), + $folder + ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $connection = @imap_open( $mailbox, $auth->getUser(), $auth->getPassword() ); + + if ( false === $connection ) { + return array( + 'success' => false, + 'error' => 'IMAP connection failed: ' . imap_last_error(), + ); + } + + return $connection; + } + + /** + * Get the IMAP auth provider. + * + * @return \DataMachine\Core\Steps\Fetch\Handlers\Email\EmailAuth|null + */ + private function getAuthProvider(): ?object { + $providers = apply_filters( 'datamachine_auth_providers', array() ); + return $providers['email_imap'] ?? null; + } + + /** + * Build IMAP mailbox connection string. + */ + private function buildMailboxString( string $host, int $port, string $encryption, string $folder ): string { + $flags = match ( $encryption ) { + 'ssl' => '/imap/ssl/validate-cert', + 'tls' => '/imap/tls/validate-cert', + default => '/imap/notls', + }; + + return sprintf( '{%s:%d%s}%s', $host, $port, $flags, $folder ); + } +} diff --git a/inc/Abilities/Fetch/FetchEmailAbility.php b/inc/Abilities/Fetch/FetchEmailAbility.php new file mode 100644 index 00000000..6386020c --- /dev/null +++ b/inc/Abilities/Fetch/FetchEmailAbility.php @@ -0,0 +1,695 @@ +registerAbilities(); + self::$registered = true; + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/fetch-email', + array( + 'label' => __( 'Fetch Emails', 'data-machine' ), + 'description' => __( 'Retrieve emails from an IMAP inbox', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'imap_host', 'imap_user', 'imap_password' ), + 'properties' => array( + 'imap_host' => array( + 'type' => 'string', + 'description' => __( 'IMAP server hostname (e.g., imap.gmail.com)', 'data-machine' ), + ), + 'imap_port' => array( + 'type' => 'integer', + 'default' => 993, + 'description' => __( 'IMAP server port', 'data-machine' ), + ), + 'imap_encryption' => array( + 'type' => 'string', + 'default' => 'ssl', + 'description' => __( 'Connection encryption: ssl, tls, or none', 'data-machine' ), + ), + 'imap_user' => array( + 'type' => 'string', + 'description' => __( 'IMAP username (usually your email address)', 'data-machine' ), + ), + 'imap_password' => array( + 'type' => 'string', + 'description' => __( 'IMAP app password (not your account password)', 'data-machine' ), + ), + 'folder' => array( + 'type' => 'string', + 'default' => 'INBOX', + 'description' => __( 'Mail folder to fetch from', 'data-machine' ), + ), + 'search_criteria' => array( + 'type' => 'string', + 'default' => 'UNSEEN', + 'description' => __( 'IMAP search string (UNSEEN, ALL, FROM "x", SINCE "1-Mar-2026")', 'data-machine' ), + ), + 'max_messages' => array( + 'type' => 'integer', + 'default' => 10, + 'description' => __( 'Maximum number of messages to retrieve', 'data-machine' ), + ), + 'offset' => array( + 'type' => 'integer', + 'default' => 0, + 'description' => __( 'Number of messages to skip (for pagination)', 'data-machine' ), + ), + 'uid' => array( + 'type' => 'integer', + 'default' => 0, + 'description' => __( 'Fetch a single message by UID (detail mode)', 'data-machine' ), + ), + 'headers_only' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Fetch headers only (fast list mode — no body parsing)', 'data-machine' ), + ), + 'mark_as_read' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Mark fetched messages as read', 'data-machine' ), + ), + 'download_attachments' => array( + 'type' => 'boolean', + 'default' => false, + 'description' => __( 'Download email attachments to local storage', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'data' => array( + 'type' => 'object', + 'properties' => array( + 'items' => array( 'type' => 'array' ), + 'count' => array( 'type' => 'integer' ), + 'total_matches' => array( 'type' => 'integer' ), + 'offset' => array( 'type' => 'integer' ), + 'has_more' => array( 'type' => 'boolean' ), + ), + ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Permission callback. + * + * @return bool True if user has permission. + */ + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Execute email fetch. + * + * Three modes: + * - uid > 0: fetch single message (detail view) + * - headers_only: fast header scan (list view) + * - default: fetch with bodies (pipeline mode) + * + * @param array $input Input parameters. + * @return array Result with items or error. + */ + public function execute( array $input ): array { + $logs = array(); + + if ( ! function_exists( 'imap_open' ) ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email Fetch: PHP IMAP extension is not installed', + ); + return array( + 'success' => false, + 'error' => 'PHP IMAP extension is required but not installed. Install php-imap and restart your web server.', + 'logs' => $logs, + ); + } + + $config = $this->normalizeConfig( $input ); + + $mailbox = $this->buildMailboxString( + $config['imap_host'], + $config['imap_port'], + $config['imap_encryption'], + $config['folder'] + ); + + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + $connection = @imap_open( $mailbox, $config['imap_user'], $config['imap_password'] ); + + if ( false === $connection ) { + $imap_error = imap_last_error(); + return array( + 'success' => false, + 'error' => 'IMAP connection failed: ' . $imap_error, + 'logs' => $logs, + ); + } + + // Single message fetch by UID (detail mode). + if ( ! empty( $config['uid'] ) ) { + $item = $this->fetchMessage( $connection, (int) $config['uid'], $config ); + imap_close( $connection ); + + if ( null === $item ) { + return array( + 'success' => false, + 'error' => 'Message UID ' . $config['uid'] . ' not found', + 'logs' => $logs, + ); + } + + return array( + 'success' => true, + 'data' => array( + 'items' => array( $item ), + 'count' => 1, + 'total_matches' => 1, + 'offset' => 0, + 'has_more' => false, + ), + 'logs' => $logs, + ); + } + + // Search for messages. + $message_ids = imap_search( $connection, $config['search_criteria'], SE_UID ); + + if ( false === $message_ids || empty( $message_ids ) ) { + imap_close( $connection ); + return array( + 'success' => true, + 'data' => array( + 'items' => array(), + 'count' => 0, + 'total_matches' => 0, + 'offset' => 0, + 'has_more' => false, + ), + 'logs' => $logs, + ); + } + + // Most recent first. + $message_ids = array_reverse( $message_ids ); + $total_matches = count( $message_ids ); + $offset = (int) $config['offset']; + $max = (int) $config['max_messages']; + + // Apply pagination. + if ( $offset > 0 ) { + $message_ids = array_slice( $message_ids, $offset ); + } + $message_ids = array_slice( $message_ids, 0, $max ); + $has_more = ( $offset + count( $message_ids ) ) < $total_matches; + + $items = array(); + + if ( $config['headers_only'] ) { + // Fast header-only mode — no body parsing, no structure fetching. + foreach ( $message_ids as $uid ) { + $item = $this->fetchHeaders( $connection, $uid ); + if ( null !== $item ) { + $items[] = $item; + } + } + } else { + // Full mode — bodies + attachments. + foreach ( $message_ids as $uid ) { + $item = $this->fetchMessage( $connection, $uid, $config ); + if ( null !== $item ) { + $items[] = $item; + } + } + } + + // Mark as read if requested. + if ( $config['mark_as_read'] && ! empty( $items ) ) { + foreach ( $message_ids as $uid ) { + imap_setflag_full( $connection, (string) $uid, '\\Seen', ST_UID ); + } + $logs[] = array( + 'level' => 'info', + 'message' => sprintf( 'Email Fetch: Marked %d messages as read', count( $items ) ), + ); + } + + imap_close( $connection ); + + $logs[] = array( + 'level' => 'info', + 'message' => sprintf( + 'Email Fetch: Retrieved %d of %d messages (offset %d)', + count( $items ), + $total_matches, + $offset + ), + ); + + return array( + 'success' => true, + 'data' => array( + 'items' => $items, + 'count' => count( $items ), + 'total_matches' => $total_matches, + 'offset' => $offset, + 'has_more' => $has_more, + ), + 'logs' => $logs, + ); + } + + /** + * Fetch headers only for a message (fast list mode). + * + * No body parsing, no structure fetching, no attachment scanning. + * Just From, Subject, Date, UID, and basic flags. + * + * @param resource $connection IMAP connection. + * @param int $uid Message UID. + * @return array|null Header data or null on failure. + */ + private function fetchHeaders( $connection, int $uid ): ?array { + $msgno = imap_msgno( $connection, $uid ); + if ( 0 === $msgno ) { + return null; + } + + $header_info = imap_headerinfo( $connection, $msgno ); + if ( false === $header_info ) { + return null; + } + + $subject = isset( $header_info->subject ) ? imap_utf8( $header_info->subject ) : ''; + $from = $header_info->from[0] ?? null; + $from_email = $from ? ( $from->mailbox . '@' . $from->host ) : ''; + $from_name = isset( $from->personal ) ? imap_utf8( $from->personal ) : ''; + $date = isset( $header_info->date ) ? gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $header_info->date ) ) : ''; + $message_id = $header_info->message_id ?? ''; + + // Read flags. + $seen = ( isset( $header_info->Unseen ) && 'U' === $header_info->Unseen ) ? false : true; + $flagged = ( isset( $header_info->Flagged ) && 'F' === $header_info->Flagged ); + + // Get to address. + $to_address = ''; + if ( ! empty( $header_info->to ) ) { + $to = $header_info->to[0]; + $to_address = $to->mailbox . '@' . $to->host; + } + + // Get size from overview (fast, no body fetch). + $overview = imap_fetch_overview( $connection, (string) $uid, FT_UID ); + $size = ( ! empty( $overview ) ) ? ( $overview[0]->size ?? 0 ) : 0; + + return array( + 'title' => $subject, + 'content' => '', + 'metadata' => array( + 'uid' => $uid, + 'message_id' => $message_id, + 'dedup_key' => $message_id, + 'from' => $from_email, + 'from_name' => $from_name, + 'to' => $to_address, + 'date' => $date, + 'original_date_gmt' => $date, + 'seen' => $seen, + 'flagged' => $flagged, + 'size' => $size, + 'in_reply_to' => $header_info->in_reply_to ?? '', + ), + ); + } + + /** + * Fetch a single message with full body by UID. + * + * @param resource $connection IMAP connection. + * @param int $uid Message UID. + * @param array $config Fetch configuration. + * @return array|null Message data or null on failure. + */ + private function fetchMessage( $connection, int $uid, array $config ): ?array { + $msgno = imap_msgno( $connection, $uid ); + if ( 0 === $msgno ) { + return null; + } + + $header_info = imap_headerinfo( $connection, $msgno ); + if ( false === $header_info ) { + return null; + } + + $subject = isset( $header_info->subject ) ? imap_utf8( $header_info->subject ) : ''; + $from = $header_info->from[0] ?? null; + $from_email = $from ? ( $from->mailbox . '@' . $from->host ) : ''; + $from_name = isset( $from->personal ) ? imap_utf8( $from->personal ) : ''; + $date = isset( $header_info->date ) ? gmdate( 'Y-m-d\TH:i:s\Z', strtotime( $header_info->date ) ) : ''; + $message_id = $header_info->message_id ?? ''; + $in_reply = $header_info->in_reply_to ?? ''; + $references = $header_info->references ?? ''; + + $seen = ( isset( $header_info->Unseen ) && 'U' === $header_info->Unseen ) ? false : true; + $flagged = ( isset( $header_info->Flagged ) && 'F' === $header_info->Flagged ); + + $to_address = ''; + if ( ! empty( $header_info->to ) ) { + $to = $header_info->to[0]; + $to_address = $to->mailbox . '@' . $to->host; + } + + // Fetch body. + $body = $this->fetchBody( $connection, $uid ); + + // Check for attachments. + $structure = imap_fetchstructure( $connection, $uid, FT_UID ); + $attachment_count = 0; + $file_info = null; + + if ( $structure ) { + $attachments = $this->findAttachments( $structure ); + $attachment_count = count( $attachments ); + + if ( $config['download_attachments'] && ! empty( $attachments ) ) { + $file_info = $this->downloadAttachment( $connection, $uid, $attachments[0] ); + } + } + + $item = array( + 'title' => $subject, + 'content' => $body, + 'metadata' => array( + 'uid' => $uid, + 'message_id' => $message_id, + 'dedup_key' => $message_id, + 'from' => $from_email, + 'from_name' => $from_name, + 'to' => $to_address, + 'date' => $date, + 'original_date_gmt' => $date, + 'seen' => $seen, + 'flagged' => $flagged, + 'has_attachments' => $attachment_count > 0, + 'attachment_count' => $attachment_count, + 'in_reply_to' => $in_reply, + 'references' => $references, + ), + ); + + if ( $file_info ) { + $item['file_info'] = $file_info; + } + + return $item; + } + + /** + * Fetch message body, preferring plain text. + * + * @param resource $connection IMAP connection. + * @param int $uid Message UID. + * @return string Message body text. + */ + private function fetchBody( $connection, int $uid ): string { + $structure = imap_fetchstructure( $connection, $uid, FT_UID ); + if ( ! $structure ) { + return ''; + } + + // Simple single-part message. + if ( empty( $structure->parts ) ) { + $body = imap_fetchbody( $connection, $uid, '1', FT_UID ); + return $this->decodeBody( $body, $structure->encoding ?? 0 ); + } + + // Multipart — find text/plain first, then text/html. + $plain_body = ''; + $html_body = ''; + + foreach ( $structure->parts as $part_index => $part ) { + $part_number = (string) ( $part_index + 1 ); + + if ( 0 === ( $part->type ?? -1 ) ) { + $subtype = strtolower( $part->subtype ?? '' ); + $body = imap_fetchbody( $connection, $uid, $part_number, FT_UID ); + $decoded = $this->decodeBody( $body, $part->encoding ?? 0 ); + + if ( 'plain' === $subtype && empty( $plain_body ) ) { + $plain_body = $decoded; + } elseif ( 'html' === $subtype && empty( $html_body ) ) { + $html_body = $decoded; + } + } + + // Check multipart/alternative nested parts. + if ( ! empty( $part->parts ) ) { + foreach ( $part->parts as $sub_index => $sub_part ) { + $sub_number = $part_number . '.' . ( $sub_index + 1 ); + + if ( 0 === ( $sub_part->type ?? -1 ) ) { + $subtype = strtolower( $sub_part->subtype ?? '' ); + $body = imap_fetchbody( $connection, $uid, $sub_number, FT_UID ); + $decoded = $this->decodeBody( $body, $sub_part->encoding ?? 0 ); + + if ( 'plain' === $subtype && empty( $plain_body ) ) { + $plain_body = $decoded; + } elseif ( 'html' === $subtype && empty( $html_body ) ) { + $html_body = $decoded; + } + } + } + } + } + + if ( ! empty( $plain_body ) ) { + return $plain_body; + } + + if ( ! empty( $html_body ) ) { + return wp_strip_all_tags( $html_body ); + } + + return ''; + } + + /** + * Decode email body based on encoding type. + */ + private function decodeBody( string $body, int $encoding ): string { + switch ( $encoding ) { + case 3: // BASE64. + return base64_decode( $body, true ) ?: $body; + case 4: // QUOTED-PRINTABLE. + return quoted_printable_decode( $body ); + case 1: // 8BIT. + case 2: // BINARY. + default: + return $body; + } + } + + /** + * Find attachment parts in message structure. + */ + private function findAttachments( object $structure ): array { + $attachments = array(); + + if ( empty( $structure->parts ) ) { + return $attachments; + } + + foreach ( $structure->parts as $part_index => $part ) { + $part_number = (string) ( $part_index + 1 ); + + $is_attachment = false; + $filename = ''; + + if ( ! empty( $part->disposition ) && 'attachment' === strtolower( $part->disposition ) ) { + $is_attachment = true; + } + + if ( ! empty( $part->dparameters ) ) { + foreach ( $part->dparameters as $param ) { + if ( 'filename' === strtolower( $param->attribute ) ) { + $filename = $param->value; + $is_attachment = true; + } + } + } + + if ( empty( $filename ) && ! empty( $part->parameters ) ) { + foreach ( $part->parameters as $param ) { + if ( 'name' === strtolower( $param->attribute ) ) { + $filename = $param->value; + $is_attachment = true; + } + } + } + + if ( $is_attachment && ! empty( $filename ) ) { + $attachments[] = array( + 'part_number' => $part_number, + 'filename' => imap_utf8( $filename ), + 'encoding' => $part->encoding ?? 0, + 'mime_type' => $this->getMimeType( $part ), + 'size' => $part->bytes ?? 0, + ); + } + } + + return $attachments; + } + + /** + * Download an attachment to temp storage. + */ + private function downloadAttachment( $connection, int $uid, array $attachment_info ): ?array { + $body = imap_fetchbody( $connection, $uid, $attachment_info['part_number'], FT_UID ); + if ( empty( $body ) ) { + return null; + } + + $decoded = $this->decodeBody( $body, $attachment_info['encoding'] ); + if ( empty( $decoded ) ) { + return null; + } + + $upload_dir = wp_upload_dir(); + $target_dir = $upload_dir['basedir'] . '/datamachine-files/email-attachments'; + + if ( ! file_exists( $target_dir ) ) { + wp_mkdir_p( $target_dir ); + } + + $safe_filename = sanitize_file_name( $attachment_info['filename'] ); + $file_path = $target_dir . '/' . wp_unique_filename( $target_dir, $safe_filename ); + + // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents + $written = file_put_contents( $file_path, $decoded ); + if ( false === $written ) { + return null; + } + + return array( + 'file_path' => $file_path, + 'mime_type' => $attachment_info['mime_type'], + 'file_size' => $written, + ); + } + + /** + * Get MIME type from IMAP part object. + */ + private function getMimeType( object $part ): string { + $types = array( + 0 => 'text', + 1 => 'multipart', + 2 => 'message', + 3 => 'application', + 4 => 'audio', + 5 => 'image', + 6 => 'video', + 7 => 'model', + 8 => 'other', + ); + + $type = $types[ $part->type ?? 3 ] ?? 'application'; + $subtype = strtolower( $part->subtype ?? 'octet-stream' ); + + return "{$type}/{$subtype}"; + } + + /** + * Build IMAP mailbox connection string. + */ + private function buildMailboxString( string $host, int $port, string $encryption, string $folder ): string { + $flags = match ( $encryption ) { + 'ssl' => '/imap/ssl/validate-cert', + 'tls' => '/imap/tls/validate-cert', + default => '/imap/notls', + }; + + return sprintf( '{%s:%d%s}%s', $host, $port, $flags, $folder ); + } + + /** + * Normalize input configuration with defaults. + */ + private function normalizeConfig( array $input ): array { + $defaults = array( + 'imap_host' => '', + 'imap_port' => 993, + 'imap_user' => '', + 'imap_password' => '', + 'imap_encryption' => 'ssl', + 'folder' => 'INBOX', + 'search_criteria' => 'UNSEEN', + 'max_messages' => 10, + 'offset' => 0, + 'uid' => 0, + 'headers_only' => false, + 'mark_as_read' => false, + 'download_attachments' => false, + ); + + return array_merge( $defaults, $input ); + } +} diff --git a/inc/Abilities/Publish/SendEmailAbility.php b/inc/Abilities/Publish/SendEmailAbility.php new file mode 100644 index 00000000..86998bfc --- /dev/null +++ b/inc/Abilities/Publish/SendEmailAbility.php @@ -0,0 +1,322 @@ +registerAbilities(); + self::$registered = true; + } + + private function registerAbilities(): void { + $register_callback = function () { + wp_register_ability( + 'datamachine/send-email', + array( + 'label' => __( 'Send Email', 'data-machine' ), + 'description' => __( 'Send an email with optional attachments via wp_mail()', 'data-machine' ), + 'category' => 'datamachine', + 'input_schema' => array( + 'type' => 'object', + 'required' => array( 'to', 'subject', 'body' ), + 'properties' => array( + 'to' => array( + 'type' => 'string', + 'description' => __( 'Comma-separated recipient email addresses', 'data-machine' ), + ), + 'cc' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Comma-separated CC addresses', 'data-machine' ), + ), + 'bcc' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Comma-separated BCC addresses', 'data-machine' ), + ), + 'subject' => array( + 'type' => 'string', + 'description' => __( 'Email subject line. Supports {month}, {year}, {site_name}, {date} placeholders.', 'data-machine' ), + ), + 'body' => array( + 'type' => 'string', + 'description' => __( 'Email body content (HTML or plain text)', 'data-machine' ), + ), + 'content_type' => array( + 'type' => 'string', + 'default' => 'text/html', + 'description' => __( 'Content type: text/html or text/plain', 'data-machine' ), + ), + 'from_name' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Sender name. Falls back to site name.', 'data-machine' ), + ), + 'from_email' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Sender email. Falls back to admin email.', 'data-machine' ), + ), + 'reply_to' => array( + 'type' => 'string', + 'default' => '', + 'description' => __( 'Reply-to email address', 'data-machine' ), + ), + 'attachments' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'default' => array(), + 'description' => __( 'Array of server file paths to attach', 'data-machine' ), + ), + ), + ), + 'output_schema' => array( + 'type' => 'object', + 'properties' => array( + 'success' => array( 'type' => 'boolean' ), + 'message' => array( 'type' => 'string' ), + 'recipients' => array( 'type' => 'array' ), + 'subject' => array( 'type' => 'string' ), + 'error' => array( 'type' => 'string' ), + 'logs' => array( 'type' => 'array' ), + ), + ), + 'execute_callback' => array( $this, 'execute' ), + 'permission_callback' => array( $this, 'checkPermission' ), + 'meta' => array( 'show_in_rest' => true ), + ) + ); + }; + + if ( doing_action( 'wp_abilities_api_init' ) ) { + $register_callback(); + } elseif ( ! did_action( 'wp_abilities_api_init' ) ) { + add_action( 'wp_abilities_api_init', $register_callback ); + } + } + + /** + * Permission callback for ability. + * + * @return bool True if user has permission. + */ + public function checkPermission(): bool { + return PermissionHelper::can_manage(); + } + + /** + * Execute email send ability. + * + * @param array $input Input parameters. + * @return array Result with success/error and logs. + */ + public function execute( array $input ): array { + $logs = array(); + $config = $this->normalizeConfig( $input ); + + // 1. Parse and validate recipients. + $to = array_map( 'trim', explode( ',', $config['to'] ) ); + $to = array_filter( $to, 'is_email' ); + + if ( empty( $to ) ) { + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: No valid recipient addresses provided', + 'data' => array( 'raw_to' => $config['to'] ), + ); + return array( + 'success' => false, + 'error' => 'No valid recipient email addresses', + 'logs' => $logs, + ); + } + + // 2. Build headers. + $headers = array(); + + // Content type. + $content_type = $config['content_type']; + if ( ! in_array( $content_type, array( 'text/html', 'text/plain' ), true ) ) { + $content_type = 'text/html'; + } + $headers[] = "Content-Type: {$content_type}; charset=UTF-8"; + + // From. + $from_name = $config['from_name'] ?: get_bloginfo( 'name' ); + $from_email = $config['from_email'] ?: get_option( 'admin_email' ); + if ( $from_name && $from_email ) { + $headers[] = sprintf( 'From: %s <%s>', $from_name, $from_email ); + } + + // Reply-To. + if ( ! empty( $config['reply_to'] ) && is_email( $config['reply_to'] ) ) { + $headers[] = 'Reply-To: ' . $config['reply_to']; + } + + // CC. + if ( ! empty( $config['cc'] ) ) { + $cc_addresses = array_map( 'trim', explode( ',', $config['cc'] ) ); + foreach ( $cc_addresses as $cc ) { + if ( is_email( $cc ) ) { + $headers[] = 'Cc: ' . $cc; + } + } + } + + // BCC. + if ( ! empty( $config['bcc'] ) ) { + $bcc_addresses = array_map( 'trim', explode( ',', $config['bcc'] ) ); + foreach ( $bcc_addresses as $bcc ) { + if ( is_email( $bcc ) ) { + $headers[] = 'Bcc: ' . $bcc; + } + } + } + + // 3. Process subject placeholders. + $subject = $this->replacePlaceholders( $config['subject'] ); + + // 4. Process body placeholders. + $body = $this->replacePlaceholders( $config['body'] ); + + // 5. Validate attachments exist. + $attachments = array(); + foreach ( $config['attachments'] as $path ) { + if ( file_exists( $path ) && is_readable( $path ) ) { + $attachments[] = $path; + $logs[] = array( + 'level' => 'info', + 'message' => 'Email: Attachment validated: ' . basename( $path ), + 'data' => array( + 'path' => $path, + 'size' => filesize( $path ), + ), + ); + } else { + $logs[] = array( + 'level' => 'warning', + 'message' => 'Email: Attachment not found or not readable: ' . $path, + ); + } + } + + $logs[] = array( + 'level' => 'debug', + 'message' => 'Email: Sending', + 'data' => array( + 'to' => $to, + 'subject' => $subject, + 'content_type' => $content_type, + 'attachment_count' => count( $attachments ), + 'body_length' => strlen( $body ), + ), + ); + + // 6. Send via wp_mail(). + $sent = wp_mail( $to, $subject, $body, $headers, $attachments ); + + if ( $sent ) { + $logs[] = array( + 'level' => 'info', + 'message' => 'Email: Sent successfully to ' . implode( ', ', $to ), + ); + + return array( + 'success' => true, + 'message' => 'Email sent to ' . implode( ', ', $to ), + 'recipients' => $to, + 'subject' => $subject, + 'logs' => $logs, + ); + } + + // wp_mail failed — attempt to extract error info. + global $phpmailer; + $error_msg = 'wp_mail() returned false'; + if ( isset( $phpmailer ) && $phpmailer instanceof \PHPMailer\PHPMailer\PHPMailer ) { + $error_msg = $phpmailer->ErrorInfo ?: $error_msg; + } + + $logs[] = array( + 'level' => 'error', + 'message' => 'Email: Send failed - ' . $error_msg, + ); + + return array( + 'success' => false, + 'error' => $error_msg, + 'logs' => $logs, + ); + } + + /** + * Normalize input configuration with defaults. + * + * @param array $input Raw input. + * @return array Normalized config. + */ + private function normalizeConfig( array $input ): array { + $defaults = array( + 'to' => '', + 'cc' => '', + 'bcc' => '', + 'subject' => '', + 'body' => '', + 'content_type' => 'text/html', + 'from_name' => '', + 'from_email' => '', + 'reply_to' => '', + 'attachments' => array(), + ); + + return array_merge( $defaults, $input ); + } + + /** + * Replace placeholders in a string. + * + * Supports: {month}, {year}, {site_name}, {date}, {admin_email} + * + * @param string $text Text with placeholders. + * @return string Text with placeholders replaced. + */ + private function replacePlaceholders( string $text ): string { + $replacements = array( + '{month}' => gmdate( 'F' ), + '{year}' => gmdate( 'Y' ), + '{site_name}' => get_bloginfo( 'name' ), + '{date}' => wp_date( get_option( 'date_format' ) ), + '{admin_email}' => get_option( 'admin_email' ), + ); + + return str_replace( array_keys( $replacements ), array_values( $replacements ), $text ); + } +} diff --git a/inc/Api/Email.php b/inc/Api/Email.php new file mode 100644 index 00000000..2b97188d --- /dev/null +++ b/inc/Api/Email.php @@ -0,0 +1,505 @@ + 'POST', + 'callback' => array( self::class, 'handle_send' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'to' => array( 'type' => 'string', 'required' => true ), + 'subject' => array( 'type' => 'string', 'required' => true ), + 'body' => array( 'type' => 'string', 'required' => true ), + 'cc' => array( 'type' => 'string', 'default' => '' ), + 'bcc' => array( 'type' => 'string', 'default' => '' ), + 'from_name' => array( 'type' => 'string', 'default' => '' ), + 'from_email' => array( 'type' => 'string', 'default' => '' ), + 'reply_to' => array( 'type' => 'string', 'default' => '' ), + 'content_type' => array( 'type' => 'string', 'default' => 'text/html' ), + 'attachments' => array( 'type' => 'array', 'default' => array() ), + ), + ) + ); + + // Fetch emails from inbox. + register_rest_route( + self::NAMESPACE, + '/email/fetch', + array( + 'methods' => 'GET', + 'callback' => array( self::class, 'handle_fetch' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'search' => array( 'type' => 'string', 'default' => 'UNSEEN' ), + 'max' => array( 'type' => 'integer', 'default' => 10 ), + 'offset' => array( 'type' => 'integer', 'default' => 0 ), + 'headers_only' => array( 'type' => 'boolean', 'default' => false ), + 'mark_as_read' => array( 'type' => 'boolean', 'default' => false ), + 'download_attachments' => array( 'type' => 'boolean', 'default' => false ), + ), + ) + ); + + // Read a single email by UID. + register_rest_route( + self::NAMESPACE, + '/email/(?P\d+)/read', + array( + 'methods' => 'GET', + 'callback' => array( self::class, 'handle_read' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'uid' => array( 'type' => 'integer', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + ), + ) + ); + + // Reply to an email. + register_rest_route( + self::NAMESPACE, + '/email/reply', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_reply' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'to' => array( 'type' => 'string', 'required' => true ), + 'subject' => array( 'type' => 'string', 'required' => true ), + 'body' => array( 'type' => 'string', 'required' => true ), + 'in_reply_to' => array( 'type' => 'string', 'required' => true ), + 'references' => array( 'type' => 'string', 'default' => '' ), + 'cc' => array( 'type' => 'string', 'default' => '' ), + 'content_type' => array( 'type' => 'string', 'default' => 'text/html' ), + ), + ) + ); + + // Delete an email. + register_rest_route( + self::NAMESPACE, + '/email/(?P\d+)', + array( + 'methods' => 'DELETE', + 'callback' => array( self::class, 'handle_delete' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'uid' => array( 'type' => 'integer', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + ), + ) + ); + + // Move an email. + register_rest_route( + self::NAMESPACE, + '/email/(?P\d+)/move', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_move' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'uid' => array( 'type' => 'integer', 'required' => true ), + 'destination' => array( 'type' => 'string', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + ), + ) + ); + + // Flag/unflag an email. + register_rest_route( + self::NAMESPACE, + '/email/(?P\d+)/flag', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_flag' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'uid' => array( 'type' => 'integer', 'required' => true ), + 'flag' => array( 'type' => 'string', 'required' => true ), + 'action' => array( 'type' => 'string', 'default' => 'set' ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + ), + ) + ); + + // Batch move. + register_rest_route( + self::NAMESPACE, + '/email/batch/move', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_batch_move' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'search' => array( 'type' => 'string', 'required' => true ), + 'destination' => array( 'type' => 'string', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'max' => array( 'type' => 'integer', 'default' => 500 ), + ), + ) + ); + + // Batch flag. + register_rest_route( + self::NAMESPACE, + '/email/batch/flag', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_batch_flag' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'search' => array( 'type' => 'string', 'required' => true ), + 'flag' => array( 'type' => 'string', 'required' => true ), + 'action' => array( 'type' => 'string', 'default' => 'set' ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'max' => array( 'type' => 'integer', 'default' => 500 ), + ), + ) + ); + + // Batch delete. + register_rest_route( + self::NAMESPACE, + '/email/batch/delete', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_batch_delete' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'search' => array( 'type' => 'string', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'max' => array( 'type' => 'integer', 'default' => 100 ), + ), + ) + ); + + // Unsubscribe from a single message. + register_rest_route( + self::NAMESPACE, + '/email/(?P\d+)/unsubscribe', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_unsubscribe' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'uid' => array( 'type' => 'integer', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + ), + ) + ); + + // Batch unsubscribe. + register_rest_route( + self::NAMESPACE, + '/email/batch/unsubscribe', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_batch_unsubscribe' ), + 'permission_callback' => array( self::class, 'check_permission' ), + 'args' => array( + 'search' => array( 'type' => 'string', 'required' => true ), + 'folder' => array( 'type' => 'string', 'default' => 'INBOX' ), + 'max' => array( 'type' => 'integer', 'default' => 20 ), + ), + ) + ); + + // Test connection. + register_rest_route( + self::NAMESPACE, + '/email/test-connection', + array( + 'methods' => 'POST', + 'callback' => array( self::class, 'handle_test_connection' ), + 'permission_callback' => array( self::class, 'check_permission' ), + ) + ); + } + + public static function check_permission(): bool { + return PermissionHelper::can_manage(); + } + + public static function handle_send( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/send-email' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Send email ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'to' => $request->get_param( 'to' ), + 'subject' => $request->get_param( 'subject' ), + 'body' => $request->get_param( 'body' ), + 'cc' => $request->get_param( 'cc' ) ?? '', + 'bcc' => $request->get_param( 'bcc' ) ?? '', + 'from_name' => $request->get_param( 'from_name' ) ?? '', + 'from_email' => $request->get_param( 'from_email' ) ?? '', + 'reply_to' => $request->get_param( 'reply_to' ) ?? '', + 'content_type' => $request->get_param( 'content_type' ) ?? 'text/html', + 'attachments' => $request->get_param( 'attachments' ) ?? array(), + ) ); + + return self::to_response( $result ); + } + + public static function handle_fetch( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $auth = self::get_imap_auth(); + if ( is_wp_error( $auth ) ) { + return $auth; + } + + $ability = wp_get_ability( 'datamachine/fetch-email' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Fetch email ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'imap_host' => $auth->getHost(), + 'imap_port' => $auth->getPort(), + 'imap_encryption' => $auth->getEncryption(), + 'imap_user' => $auth->getUser(), + 'imap_password' => $auth->getPassword(), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'search_criteria' => $request->get_param( 'search' ) ?? 'UNSEEN', + 'max_messages' => (int) ( $request->get_param( 'max' ) ?? 10 ), + 'offset' => (int) ( $request->get_param( 'offset' ) ?? 0 ), + 'headers_only' => (bool) $request->get_param( 'headers_only' ), + 'mark_as_read' => (bool) $request->get_param( 'mark_as_read' ), + 'download_attachments' => (bool) $request->get_param( 'download_attachments' ), + ) ); + + return self::to_response( $result ); + } + + public static function handle_read( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $auth = self::get_imap_auth(); + if ( is_wp_error( $auth ) ) { + return $auth; + } + + $ability = wp_get_ability( 'datamachine/fetch-email' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Fetch email ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'imap_host' => $auth->getHost(), + 'imap_port' => $auth->getPort(), + 'imap_encryption' => $auth->getEncryption(), + 'imap_user' => $auth->getUser(), + 'imap_password' => $auth->getPassword(), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'uid' => (int) $request->get_param( 'uid' ), + ) ); + + return self::to_response( $result ); + } + + /** + * Get IMAP auth provider or WP_Error. + */ + private static function get_imap_auth(): object { + $providers = apply_filters( 'datamachine_auth_providers', array() ); + $auth = $providers['email_imap'] ?? null; + + if ( ! $auth || ! $auth->is_authenticated() ) { + return new \WP_Error( 'not_configured', 'IMAP credentials not configured', array( 'status' => 400 ) ); + } + + return $auth; + } + + public static function handle_reply( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-reply' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Email reply ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'to' => $request->get_param( 'to' ), + 'subject' => $request->get_param( 'subject' ), + 'body' => $request->get_param( 'body' ), + 'in_reply_to' => $request->get_param( 'in_reply_to' ), + 'references' => $request->get_param( 'references' ) ?? '', + 'cc' => $request->get_param( 'cc' ) ?? '', + 'content_type' => $request->get_param( 'content_type' ) ?? 'text/html', + ) ); + + return self::to_response( $result ); + } + + public static function handle_delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-delete' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Email delete ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'uid' => (int) $request->get_param( 'uid' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + ) ); + + return self::to_response( $result ); + } + + public static function handle_move( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-move' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Email move ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'uid' => (int) $request->get_param( 'uid' ), + 'destination' => $request->get_param( 'destination' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + ) ); + + return self::to_response( $result ); + } + + public static function handle_flag( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-flag' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Email flag ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'uid' => (int) $request->get_param( 'uid' ), + 'flag' => $request->get_param( 'flag' ), + 'action' => $request->get_param( 'action' ) ?? 'set', + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + ) ); + + return self::to_response( $result ); + } + + public static function handle_batch_move( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-batch-move' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Batch move ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'search' => $request->get_param( 'search' ), + 'destination' => $request->get_param( 'destination' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'max' => (int) ( $request->get_param( 'max' ) ?? 500 ), + ) ); + + return self::to_response( $result ); + } + + public static function handle_batch_flag( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-batch-flag' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Batch flag ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'search' => $request->get_param( 'search' ), + 'flag' => $request->get_param( 'flag' ), + 'action' => $request->get_param( 'action' ) ?? 'set', + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'max' => (int) ( $request->get_param( 'max' ) ?? 500 ), + ) ); + + return self::to_response( $result ); + } + + public static function handle_batch_delete( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-batch-delete' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Batch delete ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'search' => $request->get_param( 'search' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'max' => (int) ( $request->get_param( 'max' ) ?? 100 ), + ) ); + + return self::to_response( $result ); + } + + public static function handle_unsubscribe( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-unsubscribe' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Unsubscribe ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'uid' => (int) $request->get_param( 'uid' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + ) ); + + return self::to_response( $result ); + } + + public static function handle_batch_unsubscribe( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-batch-unsubscribe' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Batch unsubscribe ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array( + 'search' => $request->get_param( 'search' ), + 'folder' => $request->get_param( 'folder' ) ?? 'INBOX', + 'max' => (int) ( $request->get_param( 'max' ) ?? 20 ), + ) ); + + return self::to_response( $result ); + } + + public static function handle_test_connection( \WP_REST_Request $request ): \WP_REST_Response|\WP_Error { + $ability = wp_get_ability( 'datamachine/email-test-connection' ); + if ( ! $ability ) { + return new \WP_Error( 'ability_not_found', 'Email test connection ability not available', array( 'status' => 500 ) ); + } + + $result = $ability->execute( array() ); + + return self::to_response( $result ); + } + + /** + * Convert ability result to REST response. + */ + private static function to_response( array $result ): \WP_REST_Response|\WP_Error { + if ( ! ( $result['success'] ?? false ) ) { + return new \WP_Error( + 'email_error', + $result['error'] ?? 'Operation failed', + array( 'status' => 400 ) + ); + } + + return rest_ensure_response( $result ); + } +} diff --git a/inc/Cli/Bootstrap.php b/inc/Cli/Bootstrap.php index 26ea2eea..ce347be9 100644 --- a/inc/Cli/Bootstrap.php +++ b/inc/Cli/Bootstrap.php @@ -34,6 +34,7 @@ WP_CLI::add_command( 'datamachine image', Commands\ImageCommand::class ); WP_CLI::add_command( 'datamachine github', Commands\GitHubCommand::class ); WP_CLI::add_command( 'datamachine auth', Commands\AuthCommand::class ); +WP_CLI::add_command( 'datamachine email', Commands\EmailCommand::class ); WP_CLI::add_command( 'datamachine system', Commands\SystemCommand::class ); WP_CLI::add_command( 'datamachine handlers', Commands\HandlersCommand::class ); WP_CLI::add_command( 'datamachine taxonomy', Commands\TaxonomyCommand::class ); diff --git a/inc/Cli/Commands/EmailCommand.php b/inc/Cli/Commands/EmailCommand.php new file mode 100644 index 00000000..03209ab9 --- /dev/null +++ b/inc/Cli/Commands/EmailCommand.php @@ -0,0 +1,918 @@ + + * : Comma-separated recipient email addresses. + * + * --subject= + * : Email subject. Supports {month}, {year}, {site_name}, {date} placeholders. + * + * --body= + * : Email body content (HTML or plain text). + * + * [--cc=] + * : Comma-separated CC addresses. + * + * [--bcc=] + * : Comma-separated BCC addresses. + * + * [--from-name=] + * : Sender name. Defaults to site name. + * + * [--from-email=] + * : Sender email. Defaults to admin email. + * + * [--reply-to=] + * : Reply-to address. + * + * [--content-type=] + * : Content type: text/html or text/plain. + * --- + * default: text/html + * --- + * + * [--attachments=] + * : Comma-separated file paths to attach. + * + * ## EXAMPLES + * + * wp datamachine email send --to=user@example.com --subject="Report" --body="

Hello

" + * wp datamachine email send --to=a@x.com,b@x.com --subject="Monthly {month}" --body="Report" --attachments=/tmp/report.csv + * + * @subcommand send + */ + public function send( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/send-email' ); + if ( ! $ability ) { + WP_CLI::error( 'Send email ability not available.' ); + } + + $input = array( + 'to' => $assoc_args['to'], + 'subject' => $assoc_args['subject'], + 'body' => $assoc_args['body'], + 'cc' => $assoc_args['cc'] ?? '', + 'bcc' => $assoc_args['bcc'] ?? '', + 'from_name' => $assoc_args['from-name'] ?? '', + 'from_email' => $assoc_args['from-email'] ?? '', + 'reply_to' => $assoc_args['reply-to'] ?? '', + 'content_type' => $assoc_args['content-type'] ?? 'text/html', + 'attachments' => array(), + ); + + if ( ! empty( $assoc_args['attachments'] ) ) { + $input['attachments'] = array_map( 'trim', explode( ',', $assoc_args['attachments'] ) ); + } + + $result = $ability->execute( $input ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Email sent.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Email send failed.' ); + } + } + + /** + * Fetch emails from IMAP inbox. + * + * ## OPTIONS + * + * [--folder=] + * : Mail folder to fetch from. + * --- + * default: INBOX + * --- + * + * [--search=] + * : IMAP search criteria (UNSEEN, ALL, FROM "x", SINCE "1-Mar-2026"). + * --- + * default: UNSEEN + * --- + * + * [--max=] + * : Maximum number of messages to retrieve. + * --- + * default: 10 + * --- + * + * [--offset=] + * : Number of messages to skip (for pagination). + * --- + * default: 0 + * --- + * + * [--headers-only] + * : Fast mode — fetch headers only, skip body parsing. + * + * [--mark-read] + * : Mark fetched messages as read. + * + * [--download-attachments] + * : Download email attachments. + * + * [--format=] + * : Output format. + * --- + * default: table + * options: + * - table + * - json + * - csv + * - ids + * --- + * + * [--fields=] + * : Comma-separated list of fields to display. + * --- + * default: uid,from,subject,date + * --- + * + * ## EXAMPLES + * + * wp datamachine email fetch + * wp datamachine email fetch --search=ALL --max=50 --headers-only + * wp datamachine email fetch --search='FROM "boss@company.com"' --max=5 + * wp datamachine email fetch --search=ALL --max=20 --offset=20 + * + * @subcommand fetch + */ + public function fetch( array $args, array $assoc_args ): void { + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + WP_CLI::error( 'IMAP credentials not configured. Run: wp datamachine auth save email_imap' ); + } + + $ability = wp_get_ability( 'datamachine/fetch-email' ); + if ( ! $ability ) { + WP_CLI::error( 'Fetch email ability not available.' ); + } + + $input = array( + 'imap_host' => $auth->getHost(), + 'imap_port' => $auth->getPort(), + 'imap_encryption' => $auth->getEncryption(), + 'imap_user' => $auth->getUser(), + 'imap_password' => $auth->getPassword(), + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'search_criteria' => $assoc_args['search'] ?? 'UNSEEN', + 'max_messages' => (int) ( $assoc_args['max'] ?? 10 ), + 'offset' => (int) ( $assoc_args['offset'] ?? 0 ), + 'headers_only' => isset( $assoc_args['headers-only'] ), + 'mark_as_read' => isset( $assoc_args['mark-read'] ), + 'download_attachments' => isset( $assoc_args['download-attachments'] ), + ); + + $result = $ability->execute( $input ); + + if ( ! ( $result['success'] ?? false ) ) { + WP_CLI::error( $result['error'] ?? 'Fetch failed.' ); + } + + $data = $result['data'] ?? array(); + $items = $data['items'] ?? array(); + + if ( empty( $items ) ) { + WP_CLI::success( 'No messages found.' ); + return; + } + + // Flatten items for table display. + $rows = array(); + foreach ( $items as $item ) { + $meta = $item['metadata'] ?? array(); + $rows[] = array( + 'uid' => $meta['uid'] ?? '', + 'from' => $meta['from'] ?? '', + 'from_name' => $meta['from_name'] ?? '', + 'to' => $meta['to'] ?? '', + 'subject' => $item['title'] ?? '', + 'date' => $meta['date'] ?? '', + 'seen' => ( $meta['seen'] ?? false ) ? 'Y' : 'N', + 'flagged' => ( $meta['flagged'] ?? false ) ? '*' : '', + 'size' => $meta['size'] ?? '', + 'attachments' => $meta['attachment_count'] ?? '', + 'message_id' => $meta['message_id'] ?? '', + 'in_reply_to' => $meta['in_reply_to'] ?? '', + ); + } + + $fields = explode( ',', $assoc_args['fields'] ?? 'uid,from,subject,date' ); + $this->format_items( $rows, $fields, $assoc_args ); + + // Pagination info. + $total = $data['total_matches'] ?? 0; + $offset = $data['offset'] ?? 0; + $has_more = $data['has_more'] ?? false; + $format = $assoc_args['format'] ?? 'table'; + + if ( 'table' === $format && $total > 0 ) { + $showing_end = $offset + count( $items ); + WP_CLI::line( '' ); + WP_CLI::line( sprintf( + 'Showing %d–%d of %d matches.%s', + $offset + 1, + $showing_end, + $total, + $has_more ? ' Use --offset=' . $showing_end . ' for next page.' : '' + ) ); + } + } + + /** + * Read a single email by UID. + * + * ## OPTIONS + * + * + * : Message UID to read. + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * [--format=] + * : Output format. + * --- + * default: text + * options: + * - text + * - json + * --- + * + * ## EXAMPLES + * + * wp datamachine email read 12345 + * wp datamachine email read 12345 --format=json + * + * @subcommand read + */ + public function read( array $args, array $assoc_args ): void { + $uid = (int) $args[0]; + if ( $uid <= 0 ) { + WP_CLI::error( 'Invalid message UID.' ); + } + + $auth = $this->getAuthProvider(); + if ( ! $auth || ! $auth->is_authenticated() ) { + WP_CLI::error( 'IMAP credentials not configured.' ); + } + + $ability = wp_get_ability( 'datamachine/fetch-email' ); + if ( ! $ability ) { + WP_CLI::error( 'Fetch email ability not available.' ); + } + + $result = $ability->execute( array( + 'imap_host' => $auth->getHost(), + 'imap_port' => $auth->getPort(), + 'imap_encryption' => $auth->getEncryption(), + 'imap_user' => $auth->getUser(), + 'imap_password' => $auth->getPassword(), + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'uid' => $uid, + ) ); + + if ( ! ( $result['success'] ?? false ) ) { + WP_CLI::error( $result['error'] ?? 'Message not found.' ); + } + + $item = $result['data']['items'][0] ?? null; + if ( ! $item ) { + WP_CLI::error( 'Message not found.' ); + } + + $format = $assoc_args['format'] ?? 'text'; + if ( 'json' === $format ) { + WP_CLI::line( wp_json_encode( $item, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ) ); + return; + } + + // Human-readable text output. + $meta = $item['metadata'] ?? array(); + WP_CLI::line( str_repeat( '─', 60 ) ); + WP_CLI::line( 'From: ' . ( $meta['from_name'] ? $meta['from_name'] . ' <' . $meta['from'] . '>' : $meta['from'] ) ); + WP_CLI::line( 'To: ' . ( $meta['to'] ?? '' ) ); + WP_CLI::line( 'Date: ' . ( $meta['date'] ?? '' ) ); + WP_CLI::line( 'Subject: ' . ( $item['title'] ?? '' ) ); + + if ( ! empty( $meta['in_reply_to'] ) ) { + WP_CLI::line( 'Reply-To: ' . $meta['in_reply_to'] ); + } + if ( ! empty( $meta['attachment_count'] ) && $meta['attachment_count'] > 0 ) { + WP_CLI::line( 'Attachments: ' . $meta['attachment_count'] ); + } + + WP_CLI::line( str_repeat( '─', 60 ) ); + WP_CLI::line( '' ); + WP_CLI::line( $item['content'] ?? '' ); + } + + /** + * Reply to an email. + * + * ## OPTIONS + * + * --to= + * : Recipient email address. + * + * --subject= + * : Reply subject (typically starts with Re:). + * + * --body= + * : Reply body content. + * + * --in-reply-to= + * : Message-ID of the email being replied to. + * + * [--references=] + * : References header chain for threading. + * + * [--cc=] + * : Comma-separated CC addresses. + * + * [--content-type=] + * : Content type. + * --- + * default: text/html + * --- + * + * ## EXAMPLES + * + * wp datamachine email reply --to=sender@x.com --subject="Re: Hello" --body="Thanks!" --in-reply-to="" + * + * @subcommand reply + */ + public function reply( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-reply' ); + if ( ! $ability ) { + WP_CLI::error( 'Email reply ability not available.' ); + } + + $input = array( + 'to' => $assoc_args['to'], + 'subject' => $assoc_args['subject'], + 'body' => $assoc_args['body'], + 'in_reply_to' => $assoc_args['in-reply-to'], + 'references' => $assoc_args['references'] ?? '', + 'cc' => $assoc_args['cc'] ?? '', + 'content_type' => $assoc_args['content-type'] ?? 'text/html', + ); + + $result = $ability->execute( $input ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Reply sent.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Reply failed.' ); + } + } + + /** + * Delete an email from the server. + * + * ## OPTIONS + * + * + * : Message UID to delete. + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine email delete 12345 + * wp datamachine email delete 12345 --folder=Trash --yes + * + * @subcommand delete + */ + public function delete( array $args, array $assoc_args ): void { + $uid = (int) $args[0]; + if ( $uid <= 0 ) { + WP_CLI::error( 'Invalid message UID.' ); + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( "Delete message UID {$uid}?" ); + } + + $ability = wp_get_ability( 'datamachine/email-delete' ); + if ( ! $ability ) { + WP_CLI::error( 'Email delete ability not available.' ); + } + + $result = $ability->execute( array( + 'uid' => $uid, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Message deleted.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Delete failed.' ); + } + } + + /** + * Move an email to a different folder. + * + * ## OPTIONS + * + * + * : Message UID to move. + * + * + * : Target folder name. + * + * [--folder=] + * : Source folder. + * --- + * default: INBOX + * --- + * + * ## EXAMPLES + * + * wp datamachine email move 12345 Archive + * wp datamachine email move 12345 "[Gmail]/Trash" --folder=INBOX + * + * @subcommand move + */ + public function move( array $args, array $assoc_args ): void { + $uid = (int) $args[0]; + $destination = $args[1] ?? ''; + + if ( $uid <= 0 || empty( $destination ) ) { + WP_CLI::error( 'Usage: wp datamachine email move ' ); + } + + $ability = wp_get_ability( 'datamachine/email-move' ); + if ( ! $ability ) { + WP_CLI::error( 'Email move ability not available.' ); + } + + $result = $ability->execute( array( + 'uid' => $uid, + 'destination' => $destination, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Message moved.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Move failed.' ); + } + } + + /** + * Set or clear a flag on an email. + * + * ## OPTIONS + * + * + * : Message UID. + * + * + * : Flag to set: Seen, Flagged, Answered, Deleted, Draft. + * + * [--action=] + * : set or clear the flag. + * --- + * default: set + * options: + * - set + * - clear + * --- + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * ## EXAMPLES + * + * wp datamachine email flag 12345 Flagged + * wp datamachine email flag 12345 Seen --action=clear + * + * @subcommand flag + */ + public function flag( array $args, array $assoc_args ): void { + $uid = (int) $args[0]; + $flag = $args[1] ?? ''; + + if ( $uid <= 0 || empty( $flag ) ) { + WP_CLI::error( 'Usage: wp datamachine email flag ' ); + } + + $ability = wp_get_ability( 'datamachine/email-flag' ); + if ( ! $ability ) { + WP_CLI::error( 'Email flag ability not available.' ); + } + + $result = $ability->execute( array( + 'uid' => $uid, + 'flag' => $flag, + 'action' => $assoc_args['action'] ?? 'set', + 'folder' => $assoc_args['folder'] ?? 'INBOX', + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Flag updated.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Flag operation failed.' ); + } + } + + /** + * Move all emails matching a search to a folder. + * + * ## OPTIONS + * + * --search= + * : IMAP search criteria (e.g., FROM "github.com", SUBJECT "newsletter"). + * + * --destination= + * : Target folder (e.g., Archive, [Gmail]/GitHub, [Gmail]/Spam). + * + * [--folder=] + * : Source folder. + * --- + * default: INBOX + * --- + * + * [--max=] + * : Maximum messages to move (safety limit). + * --- + * default: 500 + * --- + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine email batch-move --search='FROM "github.com"' --destination="[Gmail]/GitHub" + * wp datamachine email batch-move --search='FROM "linkedin.com"' --destination="[Gmail]/Trash" --yes + * wp datamachine email batch-move --search='SUBJECT "newsletter"' --destination=Newsletters --max=100 + * + * @subcommand batch-move + */ + public function batch_move( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-batch-move' ); + if ( ! $ability ) { + WP_CLI::error( 'Batch move ability not available.' ); + } + + $search = $assoc_args['search'] ?? ''; + $destination = $assoc_args['destination'] ?? ''; + + if ( empty( $search ) || empty( $destination ) ) { + WP_CLI::error( 'Both --search and --destination are required.' ); + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( "Move all messages matching '{$search}' to '{$destination}'?" ); + } + + $result = $ability->execute( array( + 'search' => $search, + 'destination' => $destination, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'max' => (int) ( $assoc_args['max'] ?? 500 ), + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Batch move complete.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Batch move failed.' ); + } + } + + /** + * Set or clear a flag on all emails matching a search. + * + * ## OPTIONS + * + * --search= + * : IMAP search criteria. + * + * + * : Flag: Seen, Flagged, Answered, Deleted, Draft. + * + * [--action=] + * : set or clear the flag. + * --- + * default: set + * options: + * - set + * - clear + * --- + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * [--max=] + * : Maximum messages to flag (safety limit). + * --- + * default: 500 + * --- + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine email batch-flag --search='FROM "linkedin.com"' Seen + * wp datamachine email batch-flag --search='FROM "github.com"' Seen --action=clear + * wp datamachine email batch-flag --search=UNSEEN Flagged --max=10 + * + * @subcommand batch-flag + */ + public function batch_flag( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-batch-flag' ); + if ( ! $ability ) { + WP_CLI::error( 'Batch flag ability not available.' ); + } + + $search = $assoc_args['search'] ?? ''; + $flag = $args[0] ?? ''; + $action = $assoc_args['action'] ?? 'set'; + + if ( empty( $search ) || empty( $flag ) ) { + WP_CLI::error( 'Both --search and a flag argument are required.' ); + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( "{$action} flag '{$flag}' on all messages matching '{$search}'?" ); + } + + $result = $ability->execute( array( + 'search' => $search, + 'flag' => $flag, + 'action' => $action, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'max' => (int) ( $assoc_args['max'] ?? 500 ), + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Batch flag complete.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Batch flag failed.' ); + } + } + + /** + * Delete all emails matching a search. + * + * ## OPTIONS + * + * --search= + * : IMAP search criteria. + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * [--max=] + * : Maximum messages to delete (safety limit). + * --- + * default: 100 + * --- + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine email batch-delete --search='FROM "spam@example.com"' + * wp datamachine email batch-delete --search='SUBJECT "unsubscribe" BEFORE "1-Jan-2025"' --max=200 --yes + * + * @subcommand batch-delete + */ + public function batch_delete( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-batch-delete' ); + if ( ! $ability ) { + WP_CLI::error( 'Batch delete ability not available.' ); + } + + $search = $assoc_args['search'] ?? ''; + if ( empty( $search ) ) { + WP_CLI::error( '--search is required.' ); + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( "DELETE all messages matching '{$search}'? This cannot be undone." ); + } + + $result = $ability->execute( array( + 'search' => $search, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'max' => (int) ( $assoc_args['max'] ?? 100 ), + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Batch delete complete.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Batch delete failed.' ); + } + } + + /** + * Unsubscribe from a mailing list. + * + * Parses the List-Unsubscribe header from the email and executes + * the unsubscribe via One-Click POST, URL GET, or mailto. + * + * ## OPTIONS + * + * + * : Message UID to unsubscribe from. + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * ## EXAMPLES + * + * wp datamachine email unsubscribe 83012 + * + * @subcommand unsubscribe + */ + public function unsubscribe( array $args, array $assoc_args ): void { + $uid = (int) $args[0]; + if ( $uid <= 0 ) { + WP_CLI::error( 'Invalid message UID.' ); + } + + $ability = wp_get_ability( 'datamachine/email-unsubscribe' ); + if ( ! $ability ) { + WP_CLI::error( 'Unsubscribe ability not available.' ); + } + + $result = $ability->execute( array( + 'uid' => $uid, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + ) ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Unsubscribed.' ); + } else { + WP_CLI::error( $result['error'] ?? 'Unsubscribe failed.' ); + } + } + + /** + * Unsubscribe from all mailing lists matching a search. + * + * Deduplicates by sender — processes one unsubscribe per unique + * From address using the most recent message's headers. + * + * ## OPTIONS + * + * --search= + * : IMAP search criteria. + * + * [--folder=] + * : Mail folder. + * --- + * default: INBOX + * --- + * + * [--max=] + * : Maximum unique senders to unsubscribe from. + * --- + * default: 20 + * --- + * + * [--yes] + * : Skip confirmation prompt. + * + * ## EXAMPLES + * + * wp datamachine email batch-unsubscribe --search='FROM "linkedin.com"' + * wp datamachine email batch-unsubscribe --search='FROM "dominos.com"' --yes + * wp datamachine email batch-unsubscribe --search='SUBJECT "newsletter"' --max=10 + * + * @subcommand batch-unsubscribe + */ + public function batch_unsubscribe( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-batch-unsubscribe' ); + if ( ! $ability ) { + WP_CLI::error( 'Batch unsubscribe ability not available.' ); + } + + $search = $assoc_args['search'] ?? ''; + if ( empty( $search ) ) { + WP_CLI::error( '--search is required.' ); + } + + if ( ! isset( $assoc_args['yes'] ) ) { + WP_CLI::confirm( "Unsubscribe from all mailing lists matching '{$search}'?" ); + } + + $result = $ability->execute( array( + 'search' => $search, + 'folder' => $assoc_args['folder'] ?? 'INBOX', + 'max' => (int) ( $assoc_args['max'] ?? 20 ), + ) ); + + if ( ! ( $result['success'] ?? false ) ) { + WP_CLI::error( $result['error'] ?? 'Batch unsubscribe failed.' ); + } + + // Show results per sender. + $results = $result['results'] ?? array(); + if ( ! empty( $results ) ) { + foreach ( $results as $r ) { + $status = ( $r['success'] ?? false ) + ? WP_CLI::colorize( '%G✓%n ' . ( $r['method'] ?? '' ) ) + : WP_CLI::colorize( '%R✗%n ' . ( $r['reason'] ?? 'failed' ) ); + WP_CLI::line( sprintf( ' %-40s %s', $r['sender'] ?? '', $status ) ); + } + WP_CLI::line( '' ); + } + + WP_CLI::success( $result['message'] ?? 'Batch unsubscribe complete.' ); + } + + /** + * Test the IMAP connection. + * + * ## EXAMPLES + * + * wp datamachine email test-connection + * + * @subcommand test-connection + */ + public function test_connection( array $args, array $assoc_args ): void { + $ability = wp_get_ability( 'datamachine/email-test-connection' ); + if ( ! $ability ) { + WP_CLI::error( 'Email test connection ability not available.' ); + } + + $result = $ability->execute( array() ); + + if ( $result['success'] ?? false ) { + WP_CLI::success( $result['message'] ?? 'Connection OK.' ); + + $info = $result['mailbox_info'] ?? array(); + if ( ! empty( $info['folders'] ) ) { + WP_CLI::line( '' ); + WP_CLI::line( 'Available folders:' ); + foreach ( $info['folders'] as $folder ) { + WP_CLI::line( ' - ' . $folder ); + } + } + } else { + WP_CLI::error( $result['error'] ?? 'Connection failed.' ); + } + } + + /** + * Get the IMAP auth provider. + * + * @return object|null + */ + private function getAuthProvider(): ?object { + $providers = apply_filters( 'datamachine_auth_providers', array() ); + return $providers['email_imap'] ?? null; + } +} diff --git a/inc/Core/Steps/Fetch/Handlers/Email/Email.php b/inc/Core/Steps/Fetch/Handlers/Email/Email.php new file mode 100644 index 00000000..e446f425 --- /dev/null +++ b/inc/Core/Steps/Fetch/Handlers/Email/Email.php @@ -0,0 +1,164 @@ +getAuthProvider( 'email_imap' ); + + if ( ! $auth || ! $auth->is_authenticated() ) { + $context->log( 'error', 'Email Fetch: IMAP credentials not configured. Set up authentication in handler settings.' ); + return array(); + } + + // Build search criteria from config. + $search_criteria = $this->buildSearchCriteria( $config ); + + // Build ability input. + $ability_input = array( + 'imap_host' => $auth->getHost(), + 'imap_port' => $auth->getPort(), + 'imap_encryption' => $auth->getEncryption(), + 'imap_user' => $auth->getUser(), + 'imap_password' => $auth->getPassword(), + 'folder' => $config['folder'] ?? 'INBOX', + 'search_criteria' => $search_criteria, + 'max_messages' => (int) ( $config['max_messages'] ?? 10 ), + 'mark_as_read' => ! empty( $config['mark_as_read'] ), + 'download_attachments' => ! empty( $config['download_attachments'] ), + ); + + // Delegate to ability. + $ability = new FetchEmailAbility(); + $result = $ability->execute( $ability_input ); + + // Relay ability logs. + if ( ! empty( $result['logs'] ) && is_array( $result['logs'] ) ) { + foreach ( $result['logs'] as $log_entry ) { + $context->log( + $log_entry['level'] ?? 'debug', + $log_entry['message'] ?? '', + $log_entry['data'] ?? array() + ); + } + } + + if ( ! $result['success'] || empty( $result['data'] ) ) { + return array(); + } + + $items = $result['data']['items'] ?? array(); + if ( empty( $items ) ) { + return array(); + } + + // Items already have dedup_key (message_id) set by ability. + return array( 'items' => $items ); + } + + /** + * Build IMAP search criteria from handler config. + * + * Starts with the base search criteria and augments with + * from_filter and timeframe_limit settings. + * + * @param array $config Handler configuration. + * @return string IMAP search criteria string. + */ + private function buildSearchCriteria( array $config ): string { + $criteria = $config['search_criteria'] ?? 'UNSEEN'; + + // Augment with sender filter. + if ( ! empty( $config['from_filter'] ) ) { + $criteria .= ' FROM "' . $config['from_filter'] . '"'; + } + + // Augment with subject filter. + if ( ! empty( $config['subject_filter'] ) ) { + $criteria .= ' SUBJECT "' . $config['subject_filter'] . '"'; + } + + // Augment with timeframe filter. + if ( ! empty( $config['timeframe_limit'] ) && 'all_time' !== $config['timeframe_limit'] ) { + $since_date = $this->timeframeToDate( $config['timeframe_limit'] ); + if ( $since_date ) { + $criteria .= ' SINCE "' . $since_date . '"'; + } + } + + return $criteria; + } + + /** + * Convert timeframe limit to IMAP date string. + * + * @param string $timeframe Timeframe identifier. + * @return string|null IMAP-formatted date or null. + */ + private function timeframeToDate( string $timeframe ): ?string { + $days_map = array( + '24_hours' => 1, + '7_days' => 7, + '30_days' => 30, + '90_days' => 90, + '6_months' => 180, + '1_year' => 365, + ); + + $days = $days_map[ $timeframe ] ?? null; + if ( null === $days ) { + return null; + } + + return gmdate( 'j-M-Y', strtotime( "-{$days} days" ) ); + } +} diff --git a/inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php b/inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php new file mode 100644 index 00000000..1631d679 --- /dev/null +++ b/inc/Core/Steps/Fetch/Handlers/Email/EmailAuth.php @@ -0,0 +1,153 @@ + array( + 'type' => 'text', + 'label' => __( 'IMAP Host', 'data-machine' ), + 'placeholder' => 'imap.gmail.com', + 'description' => __( 'IMAP server hostname.', 'data-machine' ), + 'required' => true, + ), + 'imap_port' => array( + 'type' => 'number', + 'label' => __( 'IMAP Port', 'data-machine' ), + 'default' => 993, + 'description' => __( 'IMAP server port. Usually 993 for SSL.', 'data-machine' ), + ), + 'imap_encryption' => array( + 'type' => 'select', + 'label' => __( 'Encryption', 'data-machine' ), + 'options' => array( + 'ssl' => 'SSL', + 'tls' => 'TLS', + 'none' => __( 'None', 'data-machine' ), + ), + 'default' => 'ssl', + 'description' => __( 'Connection encryption method.', 'data-machine' ), + ), + 'imap_user' => array( + 'type' => 'text', + 'label' => __( 'Username', 'data-machine' ), + 'placeholder' => 'your-email@gmail.com', + 'description' => __( 'Your email address (used as IMAP username).', 'data-machine' ), + 'required' => true, + ), + 'imap_password' => array( + 'type' => 'password', + 'label' => __( 'App Password', 'data-machine' ), + 'description' => __( 'An app-specific password (not your account password). Generate one in your email provider\'s security settings.', 'data-machine' ), + 'required' => true, + ), + ); + } + + /** + * Check if IMAP credentials are configured. + * + * @return bool True if authenticated (credentials saved). + */ + public function is_authenticated(): bool { + $config = $this->get_config(); + return ! empty( $config['imap_host'] ) + && ! empty( $config['imap_user'] ) + && ! empty( $config['imap_password'] ); + } + + /** + * Get IMAP host. + * + * @return string IMAP hostname. + */ + public function getHost(): string { + $config = $this->get_config(); + return $config['imap_host'] ?? ''; + } + + /** + * Get IMAP port. + * + * @return int IMAP port number. + */ + public function getPort(): int { + $config = $this->get_config(); + return (int) ( $config['imap_port'] ?? 993 ); + } + + /** + * Get IMAP encryption type. + * + * @return string Encryption type (ssl, tls, none). + */ + public function getEncryption(): string { + $config = $this->get_config(); + return $config['imap_encryption'] ?? 'ssl'; + } + + /** + * Get IMAP username. + * + * @return string IMAP username. + */ + public function getUser(): string { + $config = $this->get_config(); + return $config['imap_user'] ?? ''; + } + + /** + * Get IMAP password. + * + * @return string IMAP app password. + */ + public function getPassword(): string { + $config = $this->get_config(); + return $config['imap_password'] ?? ''; + } + + /** + * Get account details for display. + * + * @return array|null Account display details. + */ + public function get_account_details(): ?array { + if ( ! $this->is_authenticated() ) { + return null; + } + + $config = $this->get_config(); + return array( + 'email' => $config['imap_user'] ?? '', + 'host' => $config['imap_host'] ?? '', + 'port' => $config['imap_port'] ?? 993, + 'encryption' => $config['imap_encryption'] ?? 'ssl', + ); + } +} diff --git a/inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php b/inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php new file mode 100644 index 00000000..57adae38 --- /dev/null +++ b/inc/Core/Steps/Fetch/Handlers/Email/EmailFetchSettings.php @@ -0,0 +1,65 @@ + array( + 'type' => 'text', + 'label' => __( 'Mail Folder', 'data-machine' ), + 'default' => 'INBOX', + 'description' => __( 'IMAP folder to fetch from (e.g., INBOX, Sent, [Gmail]/All Mail).', 'data-machine' ), + ), + 'search_criteria' => array( + 'type' => 'text', + 'label' => __( 'Search Criteria', 'data-machine' ), + 'default' => 'UNSEEN', + 'description' => __( 'IMAP search string. Examples: UNSEEN, ALL, FLAGGED, FROM "user@example.com", SINCE "1-Mar-2026".', 'data-machine' ), + ), + 'from_filter' => array( + 'type' => 'text', + 'label' => __( 'From Filter', 'data-machine' ), + 'description' => __( 'Only fetch emails from this sender address.', 'data-machine' ), + ), + 'subject_filter' => array( + 'type' => 'text', + 'label' => __( 'Subject Filter', 'data-machine' ), + 'description' => __( 'Only fetch emails with this text in the subject.', 'data-machine' ), + ), + 'mark_as_read' => array( + 'type' => 'checkbox', + 'label' => __( 'Mark as Read', 'data-machine' ), + 'default' => false, + 'description' => __( 'Mark fetched messages as read (Seen) after processing.', 'data-machine' ), + ), + 'download_attachments' => array( + 'type' => 'checkbox', + 'label' => __( 'Download Attachments', 'data-machine' ), + 'default' => false, + 'description' => __( 'Download email attachments to local storage for pipeline processing.', 'data-machine' ), + ), + ); + + // Merge with common fetch handler fields (timeframe, keywords, max_items). + return array_merge( $fields, parent::get_common_fields() ); + } +} diff --git a/inc/Core/Steps/Publish/Handlers/Email/Email.php b/inc/Core/Steps/Publish/Handlers/Email/Email.php new file mode 100644 index 00000000..8664b877 --- /dev/null +++ b/inc/Core/Steps/Publish/Handlers/Email/Email.php @@ -0,0 +1,162 @@ + self::class, + 'method' => 'handle_tool_call', + 'handler' => 'email_publish', + 'description' => 'Send an email. Compose the subject and body (HTML). Optionally override recipients and attach files by providing server file paths.', + 'parameters' => array( + 'to' => array( + 'type' => 'string', + 'required' => false, + 'description' => 'Comma-separated recipient emails. Leave empty to use the default recipients from handler settings.', + ), + 'subject' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Email subject. Supports {month}, {year}, {site_name} placeholders.', + ), + 'body' => array( + 'type' => 'string', + 'required' => true, + 'description' => 'Email body content in HTML format.', + ), + 'attachments' => array( + 'type' => 'array', + 'items' => array( 'type' => 'string' ), + 'required' => false, + 'description' => 'Array of server file paths to attach to the email.', + ), + ), + 'handler_config' => $handler_config, + ); + } + return $tools; + } + ); + } + + /** + * Execute email publish. + * + * Merges handler-level defaults with AI-provided parameters, + * then delegates to SendEmailAbility. + * + * @param array $parameters Tool call parameters. + * @param array $handler_config Handler configuration. + * @return array Success/error response. + */ + protected function executePublish( array $parameters, array $handler_config ): array { + // Resolve recipients: AI override → handler default → admin email. + $to = ! empty( $parameters['to'] ) + ? $parameters['to'] + : ( $handler_config['default_to'] ?? get_option( 'admin_email' ) ); + + $subject = $parameters['subject'] + ?? $handler_config['default_subject'] + ?? __( 'Data Machine Report', 'data-machine' ); + + $body = $parameters['body'] ?? ''; + $attachments = $parameters['attachments'] ?? array(); + + // Resolve attachment paths from engine data if available. + $engine = $parameters['engine'] ?? null; + if ( $engine instanceof EngineData ) { + $engine_attachments = $engine->get( 'email_attachments' ); + if ( is_array( $engine_attachments ) ) { + $attachments = array_merge( $attachments, $engine_attachments ); + } + } + + // Build ability input. + $ability_input = array( + 'to' => $to, + 'cc' => $handler_config['default_cc'] ?? '', + 'bcc' => $handler_config['default_bcc'] ?? '', + 'subject' => $subject, + 'body' => $body, + 'content_type' => $handler_config['content_type'] ?? 'text/html', + 'from_name' => $handler_config['from_name'] ?? '', + 'from_email' => $handler_config['from_email'] ?? '', + 'reply_to' => $handler_config['reply_to'] ?? '', + 'attachments' => $attachments, + ); + + // Delegate to ability. + $ability = new SendEmailAbility(); + $result = $ability->execute( $ability_input ); + + // Relay ability logs. + if ( ! empty( $result['logs'] ) && is_array( $result['logs'] ) ) { + foreach ( $result['logs'] as $log_entry ) { + $this->log( + $log_entry['level'] ?? 'debug', + $log_entry['message'] ?? '', + $log_entry['data'] ?? array() + ); + } + } + + if ( ! $result['success'] ) { + return $this->errorResponse( + $result['error'] ?? __( 'Email send failed', 'data-machine' ), + array( 'ability_result' => $result ) + ); + } + + return $this->successResponse( + array( + 'message' => $result['message'] ?? '', + 'recipients' => $result['recipients'] ?? array(), + 'subject' => $result['subject'] ?? '', + ) + ); + } + + /** + * Get the display label for the Email handler. + * + * @return string Handler label. + */ + public static function get_label(): string { + return 'Email'; + } +} diff --git a/inc/Core/Steps/Publish/Handlers/Email/EmailSettings.php b/inc/Core/Steps/Publish/Handlers/Email/EmailSettings.php new file mode 100644 index 00000000..a62f221f --- /dev/null +++ b/inc/Core/Steps/Publish/Handlers/Email/EmailSettings.php @@ -0,0 +1,91 @@ + array( + 'type' => 'text', + 'label' => __( 'Default To', 'data-machine' ), + 'description' => __( 'Comma-separated default recipient(s). The AI can override per execution.', 'data-machine' ), + ), + 'default_cc' => array( + 'type' => 'text', + 'label' => __( 'CC', 'data-machine' ), + 'description' => __( 'Comma-separated CC addresses.', 'data-machine' ), + ), + 'default_bcc' => array( + 'type' => 'text', + 'label' => __( 'BCC', 'data-machine' ), + 'description' => __( 'Comma-separated BCC addresses.', 'data-machine' ), + ), + + // Sender. + 'from_name' => array( + 'type' => 'text', + 'label' => __( 'From Name', 'data-machine' ), + 'description' => __( 'Sender name. Defaults to site name. May be overridden by your SMTP plugin.', 'data-machine' ), + ), + 'from_email' => array( + 'type' => 'text', + 'label' => __( 'From Email', 'data-machine' ), + 'description' => __( 'Sender email. Defaults to admin email. May be overridden by your SMTP plugin.', 'data-machine' ), + ), + 'reply_to' => array( + 'type' => 'text', + 'label' => __( 'Reply-To', 'data-machine' ), + 'description' => __( 'Reply-to address if different from sender.', 'data-machine' ), + ), + + // Content. + 'default_subject' => array( + 'type' => 'text', + 'label' => __( 'Default Subject', 'data-machine' ), + 'description' => __( 'Default subject template. Supports {month}, {year}, {site_name} placeholders.', 'data-machine' ), + ), + 'content_type' => array( + 'type' => 'select', + 'label' => __( 'Content Type', 'data-machine' ), + 'options' => array( + 'text/html' => __( 'HTML', 'data-machine' ), + 'text/plain' => __( 'Plain Text', 'data-machine' ), + ), + 'default' => 'text/html', + 'description' => __( 'Email body format.', 'data-machine' ), + ), + ); + } + + /** + * Determine if authentication is required. + * + * Email uses wp_mail() which handles transport — no auth needed at handler level. + * + * @param array $current_config Current configuration values. + * @return bool Always false. + */ + public static function requires_authentication( array $current_config = array() ): bool { + return false; + } +}