Initial commit

This commit is contained in:
gustavooth
2026-07-23 22:56:30 -03:00
commit 22a1006598
1816 changed files with 410742 additions and 0 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,948 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\ContactsQuery;
/**
* Contact-centric MCP tools.
*
* Read tools (Phase 2): listContacts, getContact.
* Write tools (Phase 3): upsertContact, bulkUpsertContacts, deleteContact,
* applySegmentsToContacts, addContactNote.
*
* Each method delegates to existing FluentCRM services (ContactsQuery,
* Subscriber model, Helper::deleteContacts, etc.) — no business-logic
* duplication — and shapes the result through MCPHelper formatters.
*/
class ContactTools
{
// -----------------------------------------------------------------
// Read: list-contacts
// -----------------------------------------------------------------
public static function listContacts($params)
{
$params = (array) $params;
// Reject up front if the caller passed an unsupported advanced_filters
// shape — round-2 review #3.
$validation = MCPHelper::validateUniversalFilter($params);
if (is_wp_error($validation)) {
return $validation;
}
$pagination = MCPHelper::paginationFromInput($params);
$args = MCPHelper::buildContactsQueryArgs($params);
$args['with'] = ['tags', 'lists'];
if (!empty($params['include_custom_fields'])) {
$args['custom_fields'] = true;
}
$cq = new ContactsQuery($args);
MCPHelper::applyDateFilters($cq, $params);
$paginated = $cq->paginate();
return MCPHelper::formatContactList($paginated, !empty($params['include_custom_fields']));
}
// -----------------------------------------------------------------
// Read: get-contact
// -----------------------------------------------------------------
public static function getContact($params)
{
$params = (array) $params;
$defaultIncludes = ['notes', 'email_history', 'automations'];
$include = isset($params['include']) && is_array($params['include']) && $params['include']
? array_values(array_intersect(
$params['include'],
['notes', 'email_history', 'automations', 'activity', 'purchase_history', 'support_tickets', 'ai_summary', 'info_widgets']
))
: $defaultIncludes;
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
$with = ['tags', 'lists'];
$subscriber = null;
if ($contactId) {
$subscriber = Subscriber::with($with)->find($contactId);
} elseif ($email) {
$subscriber = Subscriber::with($with)->where('email', $email)->first();
}
if (!$subscriber) {
if (!$contactId && !$email) {
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
}
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), array_filter([
'contact_id' => $contactId ?: null,
'email' => $email ?: null,
]));
}
$data = MCPHelper::formatContactForMCP($subscriber, ['include' => $include]);
// Defaults already inlined by formatContactForMCP — fill the optional ones.
if (in_array('activity', $include, true)) {
$data['activity'] = self::buildActivityTimeline($subscriber);
}
if (in_array('purchase_history', $include, true)) {
$data['purchase_history'] = self::buildPurchaseHistory($subscriber);
}
if (in_array('support_tickets', $include, true)) {
$data['support_tickets'] = self::buildSupportTickets($subscriber);
}
if (in_array('info_widgets', $include, true)) {
$data['info_widgets'] = self::buildInfoWidgets($subscriber);
}
if (in_array('ai_summary', $include, true)) {
$data['ai_summary'] = self::buildAiSummary($subscriber, !empty($params['generate_ai_summary']));
}
// Status-related context — surfaced inline so the agent can see why a
// contact is unsubscribed without an extra call.
if (in_array($subscriber->status, ['unsubscribed', 'bounced', 'complained', 'spammed'], true)) {
$data['unsubscribe_reason'] = method_exists($subscriber, 'unsubscribeReason')
? $subscriber->unsubscribeReason()
: null;
}
return $data;
}
/**
* Activity timeline = tracked events. The fc_event_tracking table is
* created by the free plugin's migrations but may not exist on legacy
* installs that never ran the migration. Probe with SHOW TABLES so we
* never trigger wpdb's print_error (which leaks HTML into the response
* body before the JSON envelope, even when the exception is caught).
*/
private static function buildActivityTimeline($subscriber)
{
global $wpdb;
$tableName = $wpdb->prefix . 'fc_event_tracking';
$exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $tableName)) === $tableName;
if (!$exists) {
return [];
}
try {
$events = $subscriber->trackingEvents()
->orderBy('id', 'DESC')
->limit(50)
->get();
} catch (\Throwable $e) {
return [];
}
$out = [];
foreach ($events as $event) {
$out[] = [
'id' => (int) $event->id,
'event_key' => $event->event_key,
'title' => $event->title,
'value' => $event->value,
'provider' => $event->provider ?? null,
'counter' => isset($event->counter) ? (int) $event->counter : null,
'created_at' => MCPHelper::toIso8601($event->created_at),
];
}
return $out;
}
private static function buildPurchaseHistory($subscriber)
{
/**
* Resolved per the existing FluentCRM commerce-provider filter chain.
*/
$provider = apply_filters('fluentcrm_commerce_provider', '');
if (!$provider) {
return [];
}
$stat = apply_filters('fluent_crm/contact_purchase_stat_' . $provider, [], $subscriber->id);
return is_array($stat) ? $stat : [];
}
private static function buildSupportTickets($subscriber)
{
// FluentSupport hooks this filter when active. Empty otherwise.
return apply_filters('fluentcrm_get_support_tickets', [], $subscriber);
}
private static function buildInfoWidgets($subscriber)
{
/**
* Filter that integrators (Pro, FluentSupport, FluentCart, etc.) push
* widget data into. Surface the raw filter result; ContextTools agents
* can interpret what's there.
*/
$widgets = apply_filters('fluent_crm/contact_info_widgets', [], $subscriber);
return is_array($widgets) ? $widgets : [];
}
private static function buildAiSummary($subscriber, $generate = false)
{
$cached = fluentcrm_get_subscriber_meta($subscriber->id, '_ai_summary');
if ($cached && !$generate) {
return [
'summary' => is_array($cached) ? ($cached['summary'] ?? '') : (string) $cached,
'generated_at' => is_array($cached) ? ($cached['generated_at'] ?? null) : null,
'cached' => true,
];
}
if (!$generate) {
return null;
}
// Honor existing AI controller; if it's missing or disabled, return
// a structured signal rather than throwing.
if (!class_exists('FluentCrm\\App\\Http\\Controllers\\AiController')) {
return ['summary' => null, 'cached' => false, 'error' => 'ai_unavailable'];
}
$aiSettings = fluentcrm_get_option('ai_settings', []);
if (empty($aiSettings['active_provider'])) {
return ['summary' => null, 'cached' => false, 'error' => 'ai_provider_not_configured'];
}
// Generation requires the existing controller's prompt + provider call;
// surface a dependency_missing-style signal so the agent can prompt the
// user to enable AI rather than blocking the read.
return [
'summary' => null,
'cached' => false,
'error' => 'generation_not_supported_in_mcp_v1',
'note' => 'Trigger AI summary from the contact profile UI; cached value will appear on subsequent get-contact calls.',
];
}
// -----------------------------------------------------------------
// Write: upsert-contact
// -----------------------------------------------------------------
public static function upsertContact($params)
{
$params = (array) $params;
$contactId = isset($params['contact_id']) ? (int) $params['contact_id'] : 0;
$email = isset($params['email']) ? sanitize_email($params['email']) : '';
$newEmail = isset($params['new_email']) ? sanitize_email($params['new_email']) : '';
if (!$contactId && !$email) {
return MCPHelper::error('invalid_param', __('Provide contact_id or email', 'fluent-crm'));
}
$existing = null;
if ($contactId) {
$existing = Subscriber::find($contactId);
if (!$existing) {
return MCPHelper::error('not_found', __('Contact not found', 'fluent-crm'), ['contact_id' => $contactId]);
}
// Lookup-by-id with email mismatch is fine — id wins.
$email = $existing->email;
} else {
$existing = Subscriber::where('email', $email)->first();
}
$ifExists = $params['if_exists'] ?? 'merge';
if ($existing && $ifExists === 'skip') {
return [
'ok' => true,
'action' => 'skipped',
'contact' => MCPHelper::formatContactForMCP($existing, ['include' => ['notes', 'email_history', 'automations']]),
'changes' => null,
];
}
if ($existing && $ifExists === 'error') {
return MCPHelper::error('contact_exists', __('A contact with this email already exists', 'fluent-crm'), [
'id' => (int) $existing->id,
]);
}
// Re-check the escalating capability if the agent asked us to create
// missing tags/lists — defense in depth, even though the
// permission_callback already enforced the base cap.
$autoCreateTags = !empty($params['auto_create_tags']);
$autoCreateLists = !empty($params['auto_create_lists']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
// Resolve add/remove segment payloads up-front so we can mention
// resolution failures in the response without partially applying.
$addTags = MCPHelper::resolveTagIds($params['add_tags'] ?? [], $autoCreateTags);
$removeTags = MCPHelper::resolveTagIds($params['remove_tags'] ?? [], false);
$addLists = MCPHelper::resolveListIds($params['add_lists'] ?? [], $autoCreateLists);
$removeLists = MCPHelper::resolveListIds($params['remove_lists'] ?? [], false);
// Capture the pre-rename / pre-update snapshot fields BEFORE any
// mutation. The rename block below sets $existing->email to the new
// value, so reading $existing->email after that point would return
// the new email — operator-test report 2026-05-07 #9. The full
// snapshot also feeds diffFields() so fields_updated correctly
// reports 'email' on a rename.
$previousStatus = $existing ? $existing->status : null;
$previousEmail = $existing ? $existing->email : null;
$previousSnapshot = $existing ? self::snapshotCompareFields($existing) : null;
// Email rename: when an existing contact + new_email is provided, do
// the rename in-place on the existing row BEFORE delegating to
// createOrUpdate. createOrUpdate looks up by email — passing it the
// new_email would not find a row and would create a new contact
// (review B1 round 3). The save fires fluent_crm/contact_email_changed
// through Subscriber::updateOrCreate's normal path because we then
// call it with the new email as the lookup key.
if ($existing && $newEmail && $newEmail !== $existing->email) {
$oldEmail = $existing->email;
// Make sure the new email isn't already used by another contact.
$clash = Subscriber::where('email', $newEmail)->where('id', '!=', $existing->id)->first();
if ($clash) {
return MCPHelper::error('contact_exists', __('Another contact already uses the new_email — refusing to merge silently. Resolve manually or pick a different new_email.', 'fluent-crm'), [
'new_email' => $newEmail,
'conflict_id' => (int) $clash->id,
'subject_id' => (int) $existing->id,
]);
}
$existing->email = $newEmail;
$existing->save();
do_action('fluent_crm/contact_email_changed', $existing, $oldEmail);
}
// Build the upsert payload — only fields actually provided. Lookup
// email is the post-rename value (so createOrUpdate finds the same
// row we just renamed).
$payload = [
'email' => $existing && $newEmail ? $newEmail : ($email ?: ($existing->email ?? null)),
];
$passthru = ['first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'date_of_birth', 'timezone', 'source'];
foreach ($passthru as $field) {
if (array_key_exists($field, $params) && $params[$field] !== null && $params[$field] !== '') {
$payload[$field] = $params[$field];
}
}
self::applyAddressShape($payload, $params['address'] ?? null);
if (!empty($params['custom_fields']) && is_array($params['custom_fields'])) {
// Validate against the registered schema. Unknown keys would
// otherwise be silently dropped (operator-test report
// 2026-05-07 #6) — fail closed so the agent can either
// correct the slug or call get-crm-context for the schema.
$diff = MCPHelper::diffCustomFields($params['custom_fields']);
if (!empty($diff['unknown'])) {
return MCPHelper::error('invalid_param', __('custom_fields contains slugs not in the contact custom-field schema. Refusing — silent-dropping makes the agent think the value persisted.', 'fluent-crm'), [
'unknown_custom_field_slugs' => $diff['unknown'],
'allowed_custom_field_slugs' => MCPHelper::knownContactCustomFieldSlugs(),
'tip' => 'Call get-crm-context and read enums.custom_fields_schema (or call options for the live registry) before retrying.',
]);
}
$payload['custom_values'] = $diff['known'];
}
// Only stamp source='mcp' on creation. On update, omit the field
// entirely so the model preserves whatever signup source the contact
// already has ("web", "checkout", "import", etc.). The agent can
// still pass an explicit `source` to override this when needed.
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
$payload['source'] = 'mcp';
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
unset($payload['source']);
}
// The `Subscriber::updateOrCreate` path forwards through
// FluentCrmApi('contacts')->createOrUpdate which fires the
// contact-created/updated and status-change hooks we need.
// ($previousStatus / $previousEmail were captured above, before
// the rename block — see operator-test report 2026-05-07 #9.)
$forceUpdate = true;
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, $forceUpdate, false);
if (!$contact) {
return MCPHelper::error('failed', __('Could not create or update the contact', 'fluent-crm'));
}
$action = !empty($contact->wasRecentlyCreated) ? 'created' : 'updated';
// Apply delta segment changes.
$tagsAdded = [];
$tagsRemoved = [];
$listsAdded = [];
$listsRemoved = [];
if (!empty($addTags['ids'])) {
$contact->attachTags($addTags['ids']);
foreach ($addTags['ids'] as $id) {
$tagsAdded[] = ['id' => (int) $id];
}
}
if (!empty($removeTags['ids'])) {
$contact->detachTags($removeTags['ids']);
foreach ($removeTags['ids'] as $id) {
$tagsRemoved[] = ['id' => (int) $id];
}
}
if (!empty($addLists['ids'])) {
$contact->attachLists($addLists['ids']);
foreach ($addLists['ids'] as $id) {
$listsAdded[] = ['id' => (int) $id];
}
}
if (!empty($removeLists['ids'])) {
$contact->detachLists($removeLists['ids']);
foreach ($removeLists['ids'] as $id) {
$listsRemoved[] = ['id' => (int) $id];
}
}
// Optional double opt-in trigger for newly-pending contacts.
if ($contact->status === 'pending' && !empty($params['double_optin'])) {
$contact->sendDoubleOptinEmail();
}
// Status-change reason: drop a system-style note for audit.
if (!empty($params['status_change_reason']) && $previousStatus && $previousStatus !== $contact->status) {
\FluentCrm\App\Models\SubscriberNote::create([
'subscriber_id' => $contact->id,
'type' => 'note',
'title' => __('Status changed via MCP', 'fluent-crm'),
'description' => sanitize_text_field((string) $params['status_change_reason']),
]);
}
$contact = Subscriber::with(['tags', 'lists'])->find($contact->id);
return [
'ok' => true,
'action' => $action,
'contact' => MCPHelper::formatContactForMCP($contact, ['include' => ['notes', 'email_history', 'automations']]),
'changes' => [
'fields_updated' => self::diffFields($previousSnapshot, $contact),
'tags_added' => $tagsAdded,
'tags_removed' => $tagsRemoved,
'lists_added' => $listsAdded,
'lists_removed' => $listsRemoved,
'previous_status' => $previousStatus,
'current_status' => $contact->status,
'previous_email' => $previousEmail,
'current_email' => $contact->email,
'tags_created' => $addTags['created'],
'lists_created' => $addLists['created'],
],
];
}
// -----------------------------------------------------------------
// Write: delete-contact-note (round 3 review #11)
// -----------------------------------------------------------------
public static function deleteContactNote($params)
{
$params = (array) $params;
$noteId = (int) ($params['note_id'] ?? 0);
if (!$noteId) {
return MCPHelper::error('invalid_param', __('note_id is required', 'fluent-crm'));
}
$note = \FluentCrm\App\Models\SubscriberNote::find($noteId);
if (!$note) {
return MCPHelper::error('not_found', __('Note not found', 'fluent-crm'), ['note_id' => $noteId]);
}
$deletedId = (int) $note->id;
$subscriberId = (int) $note->subscriber_id;
$title = (string) $note->title;
$note->delete();
do_action('fluent_crm/note_deleted', $deletedId, $subscriberId);
return [
'ok' => true,
'action' => 'deleted',
'deleted_id' => $deletedId,
'subscriber_id' => $subscriberId,
'deleted_title' => $title,
'note' => __('Note row removed. The contact\'s other notes and email history are unaffected.', 'fluent-crm'),
];
}
/**
* Compute the would-create list for a dry-run preview. Skips numeric
* inputs (those are id lookups, not creation candidates — review B3
* round 3) and only flags string names that have no existing match.
*/
private static function wouldCreateNames($items, $kind = 'tag')
{
$out = [];
foreach ((array) $items as $item) {
if (is_numeric($item) || $item === '' || $item === null) {
continue;
}
$name = sanitize_text_field((string) $item);
$slug = sanitize_title($name);
if ($kind === 'list') {
$hit = \FluentCrm\App\Models\Lists::where('title', $name)->orWhere('slug', $slug)->first();
} else {
$hit = \FluentCrm\App\Models\Tag::where('title', $name)->orWhere('slug', $slug)->first();
}
if (!$hit) {
$out[] = $name;
}
}
return array_values(array_unique($out));
}
/**
* Map the agent-facing {line_1, line_2, city, state, postal_code,
* country} shape onto the column-named payload that Subscriber
* createOrUpdate consumes. Mutates $payload by reference. Shared
* between upsert-contact and bulk-upsert-contacts so both stay in
* lock-step (operator-test report 2026-05-07 #5).
*/
private static function applyAddressShape(array &$payload, $address)
{
if (empty($address) || !is_array($address)) {
return;
}
$map = [
'line_1' => 'address_line_1',
'line_2' => 'address_line_2',
'city' => 'city',
'state' => 'state',
'postal_code' => 'postal_code',
'country' => 'country',
];
foreach ($map as $key => $col) {
if (isset($address[$key]) && $address[$key] !== '') {
$payload[$col] = $address[$key];
}
}
}
/**
* Snapshot the diff-relevant columns of a Subscriber before any
* in-place mutation (rename, save). diffFields() compares against
* this snapshot so fields_updated stays correct even after the row
* has been written.
*
* @return array<string,string>
*/
private static function snapshotCompareFields($subscriber)
{
$snapshot = [];
foreach (self::compareFieldNames() as $field) {
$snapshot[$field] = (string) ($subscriber->{$field} ?? '');
}
return $snapshot;
}
private static function compareFieldNames()
{
return ['email', 'first_name', 'last_name', 'prefix', 'phone', 'status', 'contact_type', 'address_line_1', 'address_line_2', 'city', 'state', 'postal_code', 'country', 'date_of_birth', 'timezone', 'source'];
}
/**
* @param array<string,string>|null $before Snapshot from snapshotCompareFields()
* @param object $after Subscriber model post-save
*/
private static function diffFields($before, $after)
{
if (!$before) {
return ['*'];
}
$changed = [];
foreach (self::compareFieldNames() as $field) {
if (($before[$field] ?? '') !== (string) ($after->{$field} ?? '')) {
$changed[] = $field;
}
}
return $changed;
}
// -----------------------------------------------------------------
// Write: bulk-upsert-contacts
// -----------------------------------------------------------------
public static function bulkUpsertContacts($params)
{
$params = (array) $params;
$contacts = (array) ($params['contacts'] ?? []);
if (!$contacts) {
return MCPHelper::error('invalid_param', __('contacts is required', 'fluent-crm'));
}
$maxBatch = (int) apply_filters('fluent_crm/mcp_bulk_cap', 500, 'bulk-upsert-contacts');
if (count($contacts) > $maxBatch) {
return MCPHelper::error('cap_reached', __('Too many contacts in a single call', 'fluent-crm'), [
'max' => $maxBatch,
'matched' => count($contacts),
]);
}
$autoCreateTags = isset($params['auto_create_tags']) ? (bool) $params['auto_create_tags'] : true;
$autoCreateLists = isset($params['auto_create_lists']) ? (bool) $params['auto_create_lists'] : true;
$ifExists = $params['if_exists'] ?? 'merge';
$doubleOptin = !empty($params['double_optin']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
$created = $updated = $skipped = $invalid = $warnings = [];
foreach ($contacts as $row) {
if (!is_array($row) || empty($row['email']) || !is_email($row['email'])) {
$invalid[] = ['email' => $row['email'] ?? null, 'reason' => 'invalid_email'];
continue;
}
$existing = Subscriber::where('email', sanitize_email($row['email']))->first();
if ($existing && $ifExists === 'skip') {
$skipped[] = ['id' => (int) $existing->id, 'email' => $existing->email];
continue;
}
if ($existing && $ifExists === 'error') {
$invalid[] = ['email' => $row['email'], 'reason' => 'contact_exists', 'id' => (int) $existing->id];
continue;
}
// Resolve segments per-row.
$tagIds = MCPHelper::resolveTagIds((array) ($row['tags'] ?? []), $autoCreateTags);
$listIds = MCPHelper::resolveListIds((array) ($row['lists'] ?? []), $autoCreateLists);
$payload = $row;
$payload['tags'] = $tagIds['ids'];
$payload['lists'] = $listIds['ids'];
// Same address-shape mapping as single upsert. Without this,
// bulk silently dropped the {line_1,...,country} object —
// operator-test report 2026-05-07 #5.
self::applyAddressShape($payload, $row['address'] ?? null);
// Same rule as upsert-contact: stamp source='mcp_bulk' only on
// creation. On update, preserve the original source unless the
// caller passed one explicitly.
if (!$existing && (!isset($payload['source']) || $payload['source'] === '')) {
$payload['source'] = 'mcp_bulk';
} elseif ($existing && (!isset($payload['source']) || $payload['source'] === '')) {
unset($payload['source']);
}
if (!empty($row['custom_fields']) && is_array($row['custom_fields'])) {
// Same diff-against-schema gate as single upsert, but
// surface unknown slugs as a per-row warning so one bad
// row doesn't fail the whole batch (operator-test report
// 2026-05-07 #6). Known keys still persist.
$diff = MCPHelper::diffCustomFields($row['custom_fields']);
if (!empty($diff['unknown'])) {
$warnings[] = [
'email' => $row['email'],
'reason' => 'unknown_custom_field_slugs',
'unknown_custom_field_slugs' => $diff['unknown'],
];
}
$payload['custom_values'] = $diff['known'];
}
$contact = FluentCrmApi('contacts')->createOrUpdate($payload, true, false);
if (!$contact) {
$invalid[] = ['email' => $row['email'], 'reason' => 'failed_to_save'];
continue;
}
if ($doubleOptin && $contact->status === 'pending') {
$contact->sendDoubleOptinEmail();
}
$entry = [
'id' => (int) $contact->id,
'email' => $contact->email,
'status' => $contact->status,
];
if (!empty($contact->wasRecentlyCreated)) {
$created[] = $entry;
} else {
$updated[] = $entry;
}
}
return [
'ok' => true,
'summary' => [
'created' => count($created),
'updated' => count($updated),
'skipped' => count($skipped),
'invalid' => count($invalid),
'warnings' => count($warnings),
],
'created' => $created,
'updated' => $updated,
'skipped' => $skipped,
'invalid' => $invalid,
'warnings' => $warnings,
];
}
// -----------------------------------------------------------------
// Write: delete-contact
// -----------------------------------------------------------------
public static function deleteContact($params)
{
$resolved = MCPHelper::resolveContact((array) $params);
if (is_wp_error($resolved)) {
return $resolved;
}
$contact = $resolved;
$deletedId = (int) $contact->id;
$deletedEmail = (string) $contact->email;
$deleteEmails = !isset($params['delete_emails']) ? true : (bool) $params['delete_emails'];
if ($deleteEmails) {
\FluentCrm\App\Models\CampaignEmail::where('subscriber_id', $deletedId)->delete();
}
$ok = \FluentCrm\App\Services\Helper::deleteContacts([$deletedId]);
if (!$ok) {
return MCPHelper::error('failed', __('Could not delete the contact', 'fluent-crm'));
}
return [
'ok' => true,
'deleted_id' => $deletedId,
'deleted_email' => $deletedEmail,
'emails_purged' => (bool) $deleteEmails,
];
}
// -----------------------------------------------------------------
// Write: apply-segments-to-contacts
// -----------------------------------------------------------------
public static function applySegmentsToContacts($params)
{
$params = (array) $params;
$autoCreateTags = !empty($params['auto_create_tags']);
$autoCreateLists = !empty($params['auto_create_lists']);
if (($autoCreateTags || $autoCreateLists)
&& !\FluentCrm\App\Services\PermissionManager::currentUserCan('fcrm_manage_contact_cats')) {
return MCPHelper::error('forbidden', __('Creating new tags/lists requires fcrm_manage_contact_cats', 'fluent-crm'));
}
$contactIds = isset($params['contact_ids']) ? array_filter(array_map('intval', (array) $params['contact_ids'])) : [];
$filter = $params['filter'] ?? null;
$dryRun = !empty($params['dry_run']);
if (!$contactIds && empty($filter)) {
return MCPHelper::error('invalid_param', __('Provide contact_ids or filter', 'fluent-crm'));
}
if ($contactIds && !empty($filter)) {
return MCPHelper::error('invalid_param', __('Provide contact_ids OR filter, not both', 'fluent-crm'));
}
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
if (!$contactIds) {
$validation = MCPHelper::validateUniversalFilter((array) $filter);
if (is_wp_error($validation)) {
return $validation;
}
$args = MCPHelper::buildContactsQueryArgs((array) $filter);
$args['with'] = []; // we just need ids
$cq = new ContactsQuery($args);
MCPHelper::applyDateFilters($cq, (array) $filter);
$query = $cq->getModel();
$matched = (int) $query->count();
// During a dry run, expose the matched count even when it
// exceeds the cap — knowing the size is the whole point of a
// preview. The agent can then batch.
if ($matched > $cap && !$dryRun) {
return MCPHelper::error('cap_reached', __('Too many contacts match the filter', 'fluent-crm'), [
'max' => $cap,
'matched' => $matched,
]);
}
$contactIds = array_map('intval', $query->limit($cap)->pluck('id')->toArray());
// Stash the true matched count so dry_run can echo it (the
// pluck call above only returns up to $cap rows).
$matchedTotal = $matched;
} else {
if (count($contactIds) > $cap && !$dryRun) {
return MCPHelper::error('cap_reached', __('Too many contact_ids in a single call', 'fluent-crm'), [
'max' => $cap,
'matched' => count($contactIds),
]);
}
$matchedTotal = count($contactIds);
}
// Resolve segment refs. Auto-create is suppressed during dry runs so
// a preview never leaves orphan tags/lists behind.
$addTags = MCPHelper::resolveTagIds((array) ($params['add_tags'] ?? []), $autoCreateTags && !$dryRun);
$removeTags = MCPHelper::resolveTagIds((array) ($params['remove_tags'] ?? []), false);
$addLists = MCPHelper::resolveListIds((array) ($params['add_lists'] ?? []), $autoCreateLists && !$dryRun);
$removeLists = MCPHelper::resolveListIds((array) ($params['remove_lists'] ?? []), false);
// Compute the would-create set: name strings the agent supplied that
// don't resolve to an existing tag/list. Numeric inputs are id
// lookups, never creation candidates (review B3 round 3).
$tagsWouldCreate = self::wouldCreateNames((array) ($params['add_tags'] ?? []), 'tag');
$listsWouldCreate = self::wouldCreateNames((array) ($params['add_lists'] ?? []), 'list');
// The "at least one" guard considers what would actually happen — if
// dry_run with names that would create, that IS work, so don't bail.
$hasAnyWork = $addTags['ids'] || $removeTags['ids']
|| $addLists['ids'] || $removeLists['ids']
|| ($dryRun && ($tagsWouldCreate || $listsWouldCreate));
if (!$hasAnyWork) {
return MCPHelper::error('invalid_param', __('Provide at least one of add_tags, remove_tags, add_lists, remove_lists', 'fluent-crm'));
}
if ($dryRun) {
$formatRefs = function ($ids) {
$out = [];
foreach ($ids as $id) {
$out[] = ['id' => (int) $id];
}
return $out;
};
$exceedsCap = $matchedTotal > $cap;
return [
'ok' => true,
'dry_run' => true,
'matched_contacts' => $matchedTotal,
'cap' => $cap,
'exceeds_cap' => $exceedsCap,
'batches_required' => $exceedsCap ? (int) ceil($matchedTotal / max(1, $cap)) : 1,
'applied_to_contacts' => 0,
'tags_added' => $formatRefs($addTags['ids']),
'tags_removed' => $formatRefs($removeTags['ids']),
'lists_added' => $formatRefs($addLists['ids']),
'lists_removed' => $formatRefs($removeLists['ids']),
'tags_would_create' => $tagsWouldCreate,
'lists_would_create' => $listsWouldCreate,
'note' => $exceedsCap
? __('Dry run — match exceeds the per-call cap. Apply by passing contact_ids in batches.', 'fluent-crm')
: __('Dry run — nothing was applied. Re-run without dry_run=true to commit.', 'fluent-crm'),
];
}
// Process in chunks so attach/detach don't load thousands of rows at
// once. Each Subscriber attach/detach already de-dupes internally.
// Track the actual touched ids (review P2 #10) so an agent can
// reverse precisely without re-running the original filter — which
// may match a different set after time passes.
$chunkSize = 200;
$applied = 0;
$appliedIds = [];
foreach (array_chunk($contactIds, $chunkSize) as $batchIds) {
$subscribers = Subscriber::whereIn('id', $batchIds)->get();
foreach ($subscribers as $sub) {
if ($addTags['ids']) {
$sub->attachTags($addTags['ids']);
}
if ($removeTags['ids']) {
$sub->detachTags($removeTags['ids']);
}
if ($addLists['ids']) {
$sub->attachLists($addLists['ids']);
}
if ($removeLists['ids']) {
$sub->detachLists($removeLists['ids']);
}
$applied++;
$appliedIds[] = (int) $sub->id;
}
}
$formatRefs = function ($ids) {
$out = [];
foreach ($ids as $id) {
$out[] = ['id' => (int) $id];
}
return $out;
};
return [
'ok' => true,
'matched_contacts' => count($contactIds),
'applied_to_contacts' => $applied,
'applied_contact_ids' => $appliedIds,
'tags_added' => $formatRefs($addTags['ids']),
'tags_removed' => $formatRefs($removeTags['ids']),
'lists_added' => $formatRefs($addLists['ids']),
'lists_removed' => $formatRefs($removeLists['ids']),
'tags_created' => $addTags['created'],
'lists_created' => $addLists['created'],
'reverse_with' => __('To reverse: re-call apply-segments-to-contacts with contact_ids=applied_contact_ids and add_*/remove_* swapped.', 'fluent-crm'),
];
}
// -----------------------------------------------------------------
// Write: add-contact-note
// -----------------------------------------------------------------
public static function addContactNote($params)
{
$params = (array) $params;
$resolved = MCPHelper::resolveContact($params);
if (is_wp_error($resolved)) {
return $resolved;
}
$subscriber = $resolved;
$title = trim((string) ($params['title'] ?? ''));
$description = (string) ($params['description'] ?? '');
$type = sanitize_key($params['type'] ?? 'note');
$allowedTypes = ['note', 'call', 'email', 'meeting', 'quote'];
if (!in_array($type, $allowedTypes, true)) {
$type = 'note';
}
if ($title === '' || $description === '') {
return MCPHelper::error('invalid_param', __('title and description are required', 'fluent-crm'));
}
$noteData = [
'subscriber_id' => $subscriber->id,
'type' => $type,
'title' => $title,
'description' => $description,
'created_at' => !empty($params['created_at']) ? sanitize_text_field($params['created_at']) : current_time('mysql'),
];
// Run through the same filter the controller does so smartcodes resolve.
$noteData['description'] = apply_filters('fluent_crm/parse_campaign_email_text', $noteData['description'], $subscriber);
$noteData = \FluentCrm\App\Services\Sanitize::contactNote($noteData);
$note = \FluentCrm\App\Models\SubscriberNote::create(wp_unslash($noteData));
do_action('fluent_crm/note_added', $note, $subscriber, $noteData);
return [
'ok' => true,
'note' => MCPHelper::formatNoteForMCP($note),
];
}
}
@@ -0,0 +1,451 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\CustomContactField;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\PermissionManager;
use FluentCrm\App\Services\Stats;
/**
* `get-crm-context` — discovery surface (MCP_PLAN.md § 5.1).
*
* The agent calls this once per session to learn:
* - who they are (current WP user, FluentCRM permissions)
* - what reference data is available (top tags, lists, custom fields)
* - which enums are valid (statuses, contact types, design templates, etc.)
* - the install's current sender configuration
* - guidelines that nudge the agent toward correct usage
*
* Cached for 60 seconds per WP user via transient. Invalidation hooks (set up
* in `MCPInit::init()` lifecycle) clear the cache when underlying reference
* data changes.
*/
class ContextTools
{
const CACHE_TTL = 60;
public static function getContext($params = [])
{
$userId = get_current_user_id();
$cacheKey = 'fluent_crm_mcp_context_' . $userId;
$cached = get_transient($cacheKey);
if (is_array($cached)) {
return $cached;
}
$context = self::buildContext($userId);
set_transient($cacheKey, $context, self::CACHE_TTL);
return $context;
}
private static function buildContext($userId)
{
$user = get_user_by('ID', $userId);
$isAdmin = $user && user_can($user, 'manage_options');
$you = [
'wp_user_id' => (int) $userId,
'name' => $user ? $user->display_name : null,
'email' => $user ? $user->user_email : null,
'is_admin' => (bool) $isAdmin,
'permissions' => array_values(PermissionManager::currentUserPermissions(false)),
];
$proActive = defined('FLUENTCAMPAIGN');
$aiState = self::detectAiProvider();
$site = [
'site_url' => site_url(),
'fluent_crm_version' => defined('FLUENTCRM_PLUGIN_VERSION') ? FLUENTCRM_PLUGIN_VERSION : null,
'fluent_campaign_active' => $proActive,
'ai_provider_configured' => $aiState['configured'],
'ai_provider' => $aiState['provider'],
'timezone' => fluentCrmGetTimezoneString(),
'current_time' => fluentCrmTimestamp(),
];
$stats = self::buildStats();
$tags = self::topTagsForContext();
$lists = self::topListsForContext();
$availableTriggers = self::formatRefList(apply_filters('fluentcrm_funnel_triggers', []), 'trigger_name');
$availableActions = self::formatRefList(apply_filters('fluentcrm_funnel_blocks', [], null), 'action_name');
$enums = [
'contact_statuses' => array_values(fluentcrm_subscriber_statuses()),
'sms_statuses' => array_values(fluentcrm_subscriber_sms_statuses()),
'contact_types' => array_values(fluentcrm_contact_types()),
'campaign_statuses' => ['draft', 'scheduled', 'pending-scheduled', 'processing', 'working', 'paused', 'archived'],
'design_templates' => array_keys(self::allowedDesignTemplates()),
'funnel_statuses' => ['draft', 'published'],
'funnel_subscriber_statuses' => ['active', 'waiting', 'completed', 'cancelled', 'skipped'],
'note_types' => ['note', 'call', 'email', 'meeting', 'quote'],
];
$defaultSender = self::buildDefaultSender();
$customFieldsSchema = ['contact' => self::buildCustomFieldSchema()];
return [
'you' => $you,
'site' => $site,
'stats' => $stats,
'tags' => $tags,
'lists' => $lists,
'available_triggers' => $availableTriggers,
'available_actions' => $availableActions,
'enums' => $enums,
'default_sender' => $defaultSender,
'custom_fields_schema' => $customFieldsSchema,
'smart_codes' => self::buildSmartCodes(),
'safety_levels' => self::buildSafetyLevels(),
'rate_hints' => self::buildRateHints(),
'mcp_capabilities' => self::buildCapabilities(),
'guidelines' => self::buildGuidelines(),
];
}
/**
* Per-tool safety classification — round-3 review R3.
*
* Lets agents branch on a stable code instead of parsing tool
* descriptions or relying on annotations alone (which only
* differentiate readonly / destructive in two coarse buckets).
*
* Levels:
* safe_render — no DB writes, no sends, no side effects
* readonly — DB reads only
* creates_or_mutates_draft — writes data the user can still review/cancel
* mutating_with_dry_run — writes data; dry_run preview available
* destructive_send — actually sends mail to a real recipient
* destructive_irrecoverable — deletion / cannot be undone
*/
private static function buildSafetyLevels()
{
return apply_filters('fluent_crm/mcp_safety_levels', [
'fluent-crm/get-crm-context' => 'readonly',
'fluent-crm/list-contacts' => 'readonly',
'fluent-crm/get-contact' => 'readonly',
'fluent-crm/list-campaigns' => 'readonly',
'fluent-crm/get-campaign' => 'readonly',
'fluent-crm/list-automations' => 'readonly',
'fluent-crm/list-funnel-subscribers' => 'readonly',
'fluent-crm/get-automation' => 'readonly',
'fluent-crm/list-sequences' => 'readonly',
'fluent-crm/get-sequence' => 'readonly',
'fluent-crm/estimate-dynamic-segment' => 'readonly',
'fluent-crm/upsert-contact' => 'creates_or_mutates_draft',
'fluent-crm/bulk-upsert-contacts' => 'creates_or_mutates_draft',
'fluent-crm/add-contact-note' => 'creates_or_mutates_draft',
'fluent-crm/upsert-campaign' => 'creates_or_mutates_draft',
'fluent-crm/apply-segments-to-contacts' => 'mutating_with_dry_run',
'fluent-crm/manage-sequence-subscribers' => 'mutating_with_dry_run',
'fluent-crm/update-contact-automation-status' => 'creates_or_mutates_draft',
'fluent-crm/manage-tag' => 'destructive_irrecoverable',
'fluent-crm/manage-list' => 'destructive_irrecoverable',
'fluent-crm/delete-contact' => 'destructive_irrecoverable',
'fluent-crm/delete-contact-note' => 'destructive_irrecoverable',
'fluent-crm/send-test-email' => 'safe_render',
'fluent-crm/send-email-to-contact' => 'destructive_send',
// change-campaign-status: per-action — schedule + delete are
// destructive in different ways. Annotation already flags it;
// the description spells out which actions are dangerous.
'fluent-crm/change-campaign-status' => 'destructive_send',
]);
}
/**
* Rate / cap hints — round-3 review R4.
*
* Surfaces the limits that are otherwise embedded only in tool
* descriptions ("Cap 5000 per call"). Lets agents pre-validate
* batch sizes deterministically.
*/
private static function buildRateHints()
{
$cap = (int) apply_filters('fluent_crm/mcp_bulk_cap', 5000, 'apply-segments-to-contacts');
return apply_filters('fluent_crm/mcp_rate_hints', [
'fluent-crm/bulk-upsert-contacts' => ['max_per_call' => 500, 'recommended_batch' => 100],
'fluent-crm/apply-segments-to-contacts' => ['max_per_call' => $cap],
'fluent-crm/manage-sequence-subscribers' => ['max_per_call' => $cap],
'fluent-crm/send-email-to-contact' => ['note' => 'Goes through the normal queue + bounce handling — site-level rate limits apply (see settings.email_settings.emails_per_second).'],
]);
}
/**
* Versioned capabilities map — round-3 review R9.
*
* Lets agents adapt their strategy across MCP versions without trial
* and error. Bump `version` whenever a capability is added/removed.
*/
private static function buildCapabilities()
{
return apply_filters('fluent_crm/mcp_capabilities', [
'version' => '1.4.0',
'supports' => [
'dry_run_apply_segments',
'send_test_email',
'smart_codes_discovery',
'safety_levels',
'rate_hints',
'manage_tags_lists',
'delete_contact_note',
'one_off_email_send',
'campaign_warnings',
'auto_suffix_title_conflict',
'list_funnel_subscribers',
'applied_contact_ids_return',
'tracking_mode_aware_stats',
'recipients_strict_validation',
'advanced_filters_provider_validation',
],
'deprecated' => [],
'breaking_changes_pending' => [],
]);
}
private static function buildStats()
{
$stats = (new Stats())->getCounts();
$todayStart = (new \DateTime('today', new \DateTimeZone(fluentCrmGetTimezoneString())))->format('Y-m-d H:i:s');
$sevenDaysAgo = gmdate('Y-m-d H:i:s', time() - (7 * DAY_IN_SECONDS));
return [
'contacts_total' => Subscriber::count(),
'contacts_subscribed' => (int) ($stats['total_subscribers']['count'] ?? Subscriber::where('status', 'subscribed')->count()),
'contacts_new_today' => Subscriber::where('created_at', '>=', $todayStart)->count(),
'campaigns_sent_last_7d' => \FluentCrm\App\Models\Campaign::where('status', 'archived')
->where('updated_at', '>=', $sevenDaysAgo)
->count(),
'automations_active' => \FluentCrm\App\Models\Funnel::where('status', 'published')->count(),
'automations_total' => \FluentCrm\App\Models\Funnel::count(),
];
}
private static function topTagsForContext($limit = 50)
{
$tags = Tag::withCount('subscribers')
->orderByDesc('subscribers_count')
->limit($limit)
->get();
$out = [];
foreach ($tags as $tag) {
$out[] = [
'id' => (int) $tag->id,
'title' => $tag->title,
'slug' => $tag->slug,
'subscribers_count' => (int) $tag->subscribers_count,
];
}
return $out;
}
private static function topListsForContext($limit = 50)
{
$lists = Lists::withCount('subscribers')
->orderByDesc('subscribers_count')
->limit($limit)
->get();
$out = [];
foreach ($lists as $list) {
$out[] = [
'id' => (int) $list->id,
'title' => $list->title,
'slug' => $list->slug,
'subscribers_count' => (int) $list->subscribers_count,
];
}
return $out;
}
private static function formatRefList($items, $keyField)
{
if (!is_array($items)) {
return [];
}
$out = [];
foreach ($items as $key => $item) {
$name = is_string($key) ? $key : ($item[$keyField] ?? null);
if (!$name) {
continue;
}
$out[] = [
'key' => $name,
'label' => $item['label'] ?? $item['title'] ?? $name,
'is_pro' => !empty($item['is_pro']),
];
}
return $out;
}
/**
* Design templates the MCP tools allow agents to select. The
* visual_builder template is intentionally excluded — it's an
* interactive Gutenberg editor experience, not something an agent
* should be authoring against. Use `mcp_allowed_design_templates`
* to publish it deliberately if a custom workflow needs it.
*/
public static function allowedDesignTemplates()
{
$defaults = [
'plain' => __('Plain', 'fluent-crm'),
'classic' => __('Classic', 'fluent-crm'),
'raw_html' => __('Raw HTML', 'fluent-crm'),
'raw_classic' => __('Raw Classic', 'fluent-crm'),
];
if (method_exists(Helper::class, 'getEmailDesignTemplates')) {
$all = Helper::getEmailDesignTemplates();
if (is_array($all) && $all) {
$excluded = ['visual_builder'];
$filtered = array_diff_key($all, array_flip($excluded));
if ($filtered) {
$defaults = $filtered;
}
}
}
/**
* Filter the design templates surfaced to MCP agents. Useful for
* adding custom templates a site has registered, or allow-listing
* visual_builder if the operator really wants agents to use it.
*
* @since 2.10.0
*
* @param array $templates Map of slug => label.
*/
return apply_filters('fluent_crm/mcp_allowed_design_templates', $defaults);
}
private static function buildDefaultSender()
{
$emailSettings = Helper::getGlobalEmailSettings();
return [
'from_name' => $emailSettings['from_name'] ?? '',
'from_email' => $emailSettings['from_email'] ?? '',
'reply_to_name' => $emailSettings['reply_to_name'] ?? '',
'reply_to_email' => $emailSettings['reply_to_email'] ?? '',
];
}
private static function buildCustomFieldSchema()
{
$model = new CustomContactField();
$global = $model->getGlobalFields();
$fields = is_array($global) ? ($global['fields'] ?? []) : [];
$out = [];
foreach ((array) $fields as $field) {
$entry = [
'key' => $field['slug'] ?? null,
'label' => $field['label'] ?? null,
'type' => $field['type'] ?? null,
];
if (!empty($field['options'])) {
$entry['options'] = array_values((array) $field['options']);
}
if ($entry['key']) {
$out[] = $entry;
}
}
return $out;
}
/**
* Flatten Helper::getGlobalSmartCodes() into a compact, agent-friendly
* shape — review #19. Preserves group structure so the agent can find
* codes by source (contact / custom fields / general / extensions).
*/
private static function buildSmartCodes()
{
if (!method_exists(Helper::class, 'getGlobalSmartCodes')) {
return [];
}
$groups = Helper::getGlobalSmartCodes();
if (!is_array($groups)) {
return [];
}
$out = [];
foreach ($groups as $group) {
$codes = [];
$shortcodes = $group['shortcodes'] ?? [];
if (is_array($shortcodes)) {
foreach ($shortcodes as $code => $label) {
$codes[] = ['code' => (string) $code, 'label' => (string) $label];
}
}
$out[] = [
'key' => $group['key'] ?? null,
'title' => $group['title'] ?? null,
'codes' => $codes,
];
}
return $out;
}
private static function detectAiProvider()
{
$aiSettings = fluentcrm_get_option('ai_settings', []);
$provider = '';
$configured = false;
if (!empty($aiSettings['active_provider'])) {
$provider = sanitize_key($aiSettings['active_provider']);
$providerCfg = $aiSettings[$provider] ?? [];
$configured = !empty($providerCfg['api_key']);
}
return ['provider' => $provider ?: null, 'configured' => $configured];
}
private static function buildGuidelines()
{
$default = "Be concise. When sending to a contact, confirm their status is 'subscribed'. " .
"Use add_tags/remove_tags for delta updates. Drafts are safe — only change-campaign-status " .
"with action=schedule causes sending. The site timezone is in site.timezone — use it when " .
"constructing scheduled_at. Use custom_fields_schema to construct valid custom_fields payloads " .
"on upsert-contact — never invent keys. Filter shape (universal): {search, tags[], lists[], " .
"statuses[], contact_type, created_after, created_before, sort_by, sort_type}.";
/**
* Filter the AI guidelines text returned in get-crm-context.
*
* Useful for shop-specific nudges (e.g. "always tag MCP-touched contacts
* with `mcp-edited`"). Keep terse — the text ships in every session's
* tool-discovery payload.
*
* @since 2.10.0
*
* @param string $default
*/
return apply_filters('fluent_crm/mcp_ai_guidelines', $default);
}
/**
* Invalidate cached context for every user. Hooked from MCPInit on the
* relevant FluentCRM events.
*/
public static function invalidateCache()
{
global $wpdb;
$like = $wpdb->esc_like('_transient_fluent_crm_mcp_context_') . '%';
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
$like = $wpdb->esc_like('_transient_timeout_fluent_crm_mcp_context_') . '%';
$wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
}
}
@@ -0,0 +1,436 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Campaign;
use FluentCrm\App\Models\CustomEmailCampaign;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Modules\MCP\Tools\ContextTools;
use FluentCrm\App\Services\BlockParser;
use FluentCrm\App\Services\Helper;
use FluentCrm\App\Services\Libs\Mailer\Mailer;
use FluentCrm\App\Services\Sanitize;
use FluentCrm\Framework\Support\Arr;
/**
* One-off email tools — wraps SubscriberController::sendCustomEmail
* (MCP_PLAN.md § 5.9), exposing every option the contact-profile "Send
* Custom Email" UI surfaces *except* the interactive ones (visual_builder
* design template and template_id picker):
*
* - subject + preheader + body
* - design_template (plain | classic | raw_html | raw_classic)
* - mailer overrides: from_name/from_email/reply_to_name/reply_to_email
* - is_transactional (auto-disables the footer to match UI behavior)
* - explicit disable_footer override
* - click/open trackers (yes|no|anonymous)
* - UTM tagging
* - free-form settings passthrough for template_config / footer_settings
*
* Reuses the normal queue + bounce + FluentSMTP plumbing so MCP-sent emails
* behave identically to one-offs sent from the contact profile.
*/
class EmailTools
{
public static function sendEmailToContact($params)
{
$params = (array) $params;
$resolved = MCPHelper::resolveContact($params);
if (is_wp_error($resolved)) {
return $resolved;
}
$contact = $resolved;
$subject = trim((string) ($params['subject'] ?? ''));
$body = (string) ($params['body'] ?? '');
if ($subject === '' || $body === '') {
return MCPHelper::error('invalid_param', __('subject and body are required', 'fluent-crm'));
}
// Status check matches the controller's gate. Phrase the error so
// the agent doesn't think `is_transactional=yes` will bypass it
// (review #8 — that flag is the *message* type, not a status
// override).
$allowedStatuses = ['subscribed', 'transactional'];
if (!in_array($contact->status, $allowedStatuses, true)) {
return MCPHelper::error('invalid_param', sprintf(
/* translators: 1: current contact status, 2: comma-separated list of allowed statuses */
__("The contact's status is '%1\$s'. To send to this contact, the contact's own status must be one of: %2\$s. The is_transactional parameter controls the message type, not the contact gate.", 'fluent-crm'),
$contact->status,
implode(', ', $allowedStatuses)
), [
'current_status' => $contact->status,
'allowed_statuses' => $allowedStatuses,
]);
}
$defaults = Helper::getGlobalEmailSettings();
$designTemplate = sanitize_key((string) ($params['design_template'] ?? 'classic'));
if ($designTemplate === '') {
$designTemplate = 'classic';
}
// Defense in depth — even though the schema enum constrains this,
// a non-honoring agent (or a direct REST call) could still try to
// pass `visual_builder` or another disallowed value. Reject server
// side with a structured error.
$allowed = array_keys(ContextTools::allowedDesignTemplates());
if (!in_array($designTemplate, $allowed, true)) {
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
'design_template' => $designTemplate,
'allowed' => $allowed,
]);
}
$isTransactional = self::yesNo($params['is_transactional'] ?? null, 'no');
// Footer toggle — UI behavior: turning on transactional auto-disables
// the global footer because transactional mail must not include a
// marketing unsubscribe link. Honor that by default; let the caller
// override explicitly.
if (array_key_exists('disable_footer', $params)) {
$disableFooter = self::yesNo($params['disable_footer'], 'no');
} else {
$disableFooter = $isTransactional === 'yes' ? 'yes' : 'no';
}
$clickTracker = self::trackerValue($params['click_tracker'] ?? null);
$openTracker = self::trackerValue($params['open_tracker'] ?? null);
// Build the mailer override block.
$fromName = sanitize_text_field((string) ($params['from_name'] ?? $defaults['from_name']));
$fromEmail = sanitize_email((string) ($params['from_email'] ?? $defaults['from_email']));
$replyToName = sanitize_text_field((string) ($params['reply_to_name'] ?? ($defaults['reply_to_name'] ?? '')));
$replyToEmail = sanitize_email((string) ($params['reply_to_email'] ?? ($defaults['reply_to_email'] ?? '')));
$mailerSettings = [
'from_name' => $fromName,
'from_email' => $fromEmail,
'reply_to_name' => $replyToName,
'reply_to_email' => $replyToEmail,
'is_custom' => 'yes',
];
// Compose the settings object the way the UI does.
$settings = [
'mailer_settings' => $mailerSettings,
'is_transactional' => $isTransactional,
'footer_settings' => [
'disable_footer' => $disableFooter,
],
'template_config' => Helper::getTemplateConfig($designTemplate),
];
if ($clickTracker !== null) {
$settings['click_tracker'] = $clickTracker;
}
if ($openTracker !== null) {
$settings['open_tracker'] = $openTracker;
}
// Allow callers to pass an arbitrary `settings` object for things
// we haven't surfaced as top-level params (e.g. visual-builder style
// overrides). Caller-provided keys win on conflict.
if (!empty($params['settings']) && is_array($params['settings'])) {
$settings = array_replace_recursive($settings, $params['settings']);
}
// Custom title for audit / log; default keeps recipient email so the
// entry is searchable in the campaign list.
$title = isset($params['title']) && $params['title'] !== ''
? sanitize_text_field((string) $params['title'])
: sprintf(__('MCP one-off to %s', 'fluent-crm'), $contact->email);
$campaignData = [
'title' => $title,
'email_subject' => $subject,
'email_pre_header' => sanitize_text_field((string) ($params['pre_header'] ?? $params['preheader'] ?? '')),
'email_body' => $body,
'design_template' => $designTemplate,
'settings' => $settings,
'status' => 'draft',
];
// UTM tagging — flatten the optional `utm` object onto the
// campaign's utm_* columns.
if (!empty($params['utm']) && is_array($params['utm'])) {
$utm = $params['utm'];
$campaignData['utm_status'] = !empty($utm['status']) ? 1 : 0;
foreach (['source', 'medium', 'campaign', 'term', 'content'] as $key) {
if (isset($utm[$key])) {
$campaignData['utm_' . $key] = sanitize_text_field((string) $utm[$key]);
}
}
}
$campaignData = Sanitize::campaign($campaignData);
// Mirror the WP_Error surfacing behavior of the controller.
add_action('wp_mail_failed', function ($wpError) {
if (method_exists(Helper::class, 'debugLog')) {
Helper::debugLog('MCP send-email-to-contact failure', $wpError->get_error_message(), 'error');
}
}, 10, 1);
$campaign = CustomEmailCampaign::create($campaignData);
$campaign->subscribe([(int) $contact->id], [
'status' => 'scheduled',
'scheduled_at' => current_time('mysql'),
]);
do_action('fluentcrm_process_contact_jobs', $contact);
return [
'ok' => true,
'campaign_id' => (int) $campaign->id,
'message' => __('Email queued for delivery', 'fluent-crm'),
'contact' => [
'id' => (int) $contact->id,
'email' => $contact->email,
],
'applied' => [
'is_transactional' => $isTransactional,
'disable_footer' => $disableFooter,
'design_template' => $designTemplate,
'from' => self::formatAddress($fromName, $fromEmail),
'reply_to' => self::formatAddress($replyToName, $replyToEmail),
],
];
}
/**
* Render an RFC-5322 "Display Name <addr>" string. Previous version
* (`trim(... ' <>')`) ate the closing `>` from any "Name (with parens)"
* — review #15.
*/
private static function formatAddress($name, $email)
{
$email = trim((string) $email);
$name = trim((string) $name);
if ($email === '') return '';
if ($name === '') return $email;
return $name . ' <' . $email . '>';
}
/**
* `send-test-email` — render and send a one-off test copy of either:
* - a saved campaign (pass campaign_id), or
* - a draft body/subject the agent supplies inline.
*
* Differs from send-email-to-contact: NO campaign record is created,
* NO subscriber is enrolled, NO row is logged to fc_campaign_emails,
* and the recipient does NOT need to be subscribed. The subject is
* prefixed with "TEST:" to match what the contact-profile UI does.
* Mirrors CampaignController::sendTestEmail.
*/
public static function sendTestEmail($params)
{
$params = (array) $params;
// Resolve recipient address — defaults to the current WP user.
$toEmail = sanitize_email((string) ($params['to_email'] ?? ''));
if (!$toEmail) {
$user = wp_get_current_user();
$toEmail = $user ? $user->user_email : '';
}
if (!$toEmail || !is_email($toEmail)) {
return MCPHelper::error('invalid_param', __('A valid to_email is required.', 'fluent-crm'));
}
// Source the email content from a saved campaign or inline params.
$campaignId = isset($params['campaign_id']) ? (int) $params['campaign_id'] : 0;
$subject = $body = $preHeader = '';
$designTemplate = '';
$settings = [];
if ($campaignId) {
// Need to bypass the global type scope so test sends work for
// custom_email_campaign / sequence_mail / etc., not just
// type='campaign'.
$campaign = Campaign::withoutGlobalScope('type')->find($campaignId);
if (!$campaign) {
return MCPHelper::error('not_found', __('Campaign not found', 'fluent-crm'), ['campaign_id' => $campaignId]);
}
$subject = (string) $campaign->email_subject;
$body = (string) $campaign->email_body;
$preHeader = (string) $campaign->email_pre_header;
$designTemplate = (string) $campaign->design_template;
$settings = is_array($campaign->settings) ? $campaign->settings : (array) maybe_unserialize($campaign->settings);
}
// Inline params override campaign-derived values.
if (isset($params['subject']) && $params['subject'] !== '') {
$subject = (string) $params['subject'];
}
if (isset($params['body']) && $params['body'] !== '') {
$body = (string) $params['body'];
}
if (isset($params['pre_header'])) {
$preHeader = (string) $params['pre_header'];
}
if (isset($params['design_template']) && $params['design_template'] !== '') {
$designTemplate = sanitize_key((string) $params['design_template']);
}
if ($designTemplate === '') {
$designTemplate = 'classic';
}
// Apply the same MCP-safe enum guard as send-email-to-contact.
$allowedTemplates = array_keys(ContextTools::allowedDesignTemplates());
if (!in_array($designTemplate, $allowedTemplates, true)) {
return MCPHelper::error('invalid_param', __('design_template not allowed via MCP', 'fluent-crm'), [
'design_template' => $designTemplate,
'allowed' => $allowedTemplates,
]);
}
if ($subject === '' || $body === '') {
return MCPHelper::error('invalid_param', __('Provide either campaign_id, or subject + body.', 'fluent-crm'));
}
// Resolve the subscriber whose data smartcodes get filled with.
// Priority: explicit against_contact_*, then to_email, then any
// subscribed contact (mirrors CampaignController fallback).
$subscriber = null;
if (!empty($params['against_contact_id'])) {
$subscriber = Subscriber::find((int) $params['against_contact_id']);
}
if (!$subscriber && !empty($params['against_contact_email'])) {
$subscriber = Subscriber::where('email', sanitize_email($params['against_contact_email']))->first();
}
if (!$subscriber) {
$subscriber = Subscriber::where('email', $toEmail)->first();
}
if (!$subscriber) {
$subscriber = Subscriber::where('status', 'subscribed')->first();
}
if (!$subscriber) {
return MCPHelper::error('not_supported', __('No subscriber found to drive smartcode rendering. Add at least one subscribed contact.', 'fluent-crm'));
}
// Catch wp_mail errors for the response.
$mailErrors = [];
$mailErrorListener = function ($wpError) use (&$mailErrors) {
$mailErrors[] = $wpError->get_error_message();
};
add_action('wp_mail_failed', $mailErrorListener, 10, 1);
// Block-template rendering — same gate the controller uses.
$rawTemplates = ['raw_html', 'raw_classic'];
if (!in_array($designTemplate, $rawTemplates, true)) {
$body = (new BlockParser($subscriber))->parse($body);
}
// Footer config — pulled from a stand-in object so we can pass non-
// persisted draft data through Helper::getFooterConfig the same way
// the controller does.
$stub = (object) [
'design_template' => $designTemplate,
'settings' => $settings ?: ['template_config' => []],
'email_pre_header' => $preHeader,
'email_body' => $body,
'email_subject' => $subject,
];
$footerConfig = method_exists(Helper::class, 'getFooterConfig') ? Helper::getFooterConfig($stub) : ['footer_content' => ''];
$footerText = Arr::get($footerConfig, 'footer_content', '');
// Run the standard parse_campaign_email_text filter chain so
// smartcodes resolve.
$body = apply_filters('fluent_crm/parse_campaign_email_text', $body, $subscriber);
$footerText = apply_filters('fluent_crm/parse_campaign_email_text', $footerText, $subscriber);
$subject = apply_filters('fluent_crm/parse_campaign_email_text', $subject, $subscriber);
$preHeader = apply_filters('fluent_crm/parse_campaign_email_text', $preHeader, $subscriber);
$footerConfig['footer_content'] = $footerText;
$templateData = [
'preHeader' => $preHeader,
'email_body' => $body,
'footer_text' => $footerText,
'footer_config' => $footerConfig,
'config' => wp_parse_args(
Arr::get($settings, 'template_config', []),
Helper::getTemplateConfig($designTemplate)
),
];
$body = apply_filters(
'fluent_crm/email-design-template-' . $designTemplate,
$body,
$templateData,
$stub,
$subscriber
);
$body = str_replace('{{crm_global_email_footer}}', $footerText, $body);
$body = str_replace('{{crm_preheader_text}}', $preHeader, $body);
$data = [
'to' => [
'email' => $toEmail,
'name' => $subscriber->full_name ?: $toEmail,
],
'subject' => 'TEST: ' . $subject,
'body' => $body,
'headers' => Helper::getMailHeadersFromSettings(Arr::get($settings, 'mailer_settings', [])),
];
if (method_exists(Helper::class, 'maybeDisableEmojiOnEmail')) {
Helper::maybeDisableEmojiOnEmail();
}
$result = Mailer::send($data, $subscriber, null, true);
remove_action('wp_mail_failed', $mailErrorListener, 10);
$sent = $result !== false && empty($mailErrors);
return [
'ok' => $sent,
'sent' => $sent,
'to' => $toEmail,
'rendered_against' => [
'contact_id' => (int) $subscriber->id,
'email' => $subscriber->email,
],
'subject_preview' => 'TEST: ' . $subject,
'design_template' => $designTemplate,
'errors' => $mailErrors,
'note' => __('Test sends bypass the queue, do not enroll the recipient, and do not appear in email_history.', 'fluent-crm'),
];
}
private static function yesNo($value, $default = 'no')
{
if ($value === null) {
return $default;
}
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
$str = strtolower((string) $value);
if (in_array($str, ['yes', 'true', '1', 'on'], true)) {
return 'yes';
}
if (in_array($str, ['no', 'false', '0', 'off', ''], true)) {
return 'no';
}
return $default;
}
private static function trackerValue($value)
{
if ($value === null || $value === '') {
return null;
}
$str = strtolower((string) $value);
if (in_array($str, ['yes', 'no', 'anonymous'], true)) {
return $str;
}
if (is_bool($value)) {
return $value ? 'yes' : 'no';
}
return null;
}
}
@@ -0,0 +1,447 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Funnel;
use FluentCrm\App\Models\FunnelSubscriber;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\Funnel\FunnelHelper;
/**
* Automation (funnel) MCP tools.
*
* Read tools (Phase 2): listAutomations, getAutomation.
* Write tools (Phase 3): updateContactAutomationStatus.
*/
class FunnelTools
{
// -----------------------------------------------------------------
// Read: list-automations
// -----------------------------------------------------------------
public static function listAutomations($params)
{
$params = (array) $params;
MCPHelper::paginationFromInput($params);
$search = sanitize_text_field((string) ($params['search'] ?? ''));
$statuses = (array) ($params['statuses'] ?? []);
$statuses = array_values(array_intersect(
array_map('sanitize_key', $statuses),
['draft', 'published']
));
// All fc_funnels columns. The framework rewrite made orderBy() throw
// LogicException on column names that don't match ^[a-zA-Z0-9_\.]+$
// — empty strings, "id ASC", "DROP TABLE", etc. — so an unguarded
// sort_by would 500 the tool. Schema is stable (migration only adds
// indexes), so hardcoding the column list avoids a per-request
// SHOW COLUMNS without restricting agents to the input_schema enum.
$allowedSortBy = [
'id', 'type', 'title', 'trigger_name', 'status', 'conditions',
'settings', 'created_by', 'created_at', 'updated_at',
];
$sortBy = sanitize_key((string) ($params['sort_by'] ?? 'id'));
if (!in_array($sortBy, $allowedSortBy, true)) {
$sortBy = 'id';
}
$sortType = strtoupper(sanitize_text_field((string) ($params['sort_type'] ?? 'DESC')));
$sortType = in_array($sortType, ['ASC', 'DESC'], true) ? $sortType : 'DESC';
$query = Funnel::withCount('subscribers')->orderBy($sortBy, $sortType);
if ($search !== '') {
global $wpdb;
$like = '%' . $wpdb->esc_like($search) . '%';
$query->where(function ($q) use ($like) {
$q->where('title', 'LIKE', $like)
->orWhere('trigger_name', 'LIKE', $like);
});
}
if (!empty($statuses)) {
$query->whereIn('status', $statuses);
}
$paginated = $query->paginate();
$items = [];
$triggerLabels = self::triggerLabelMap();
foreach ($paginated->items() as $funnel) {
$items[] = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) ($funnel->subscribers_count ?? 0),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
}
return [
'items' => $items,
'total' => (int) $paginated->total(),
'page' => (int) $paginated->currentPage(),
'per_page' => (int) $paginated->perPage(),
'pages' => (int) $paginated->lastPage(),
];
}
// -----------------------------------------------------------------
// Read: get-automation
// -----------------------------------------------------------------
public static function getAutomation($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$defaultIncludes = ['sequences', 'report'];
$include = isset($params['include']) && is_array($params['include']) && $params['include']
? array_values(array_intersect($params['include'], ['sequences', 'report']))
: $defaultIncludes;
$triggerLabels = self::triggerLabelMap();
$data = [
'id' => (int) $funnel->id,
'title' => $funnel->title,
'status' => $funnel->status,
'trigger_name' => $funnel->trigger_name,
'trigger_label' => $triggerLabels[$funnel->trigger_name] ?? $funnel->trigger_name,
'trigger_settings' => is_array($funnel->settings) ? $funnel->settings : [],
'conditions' => is_array($funnel->conditions) ? $funnel->conditions : [],
'in_progress_subscribers_count' => self::inProgressCount((int) $funnel->id),
'completed_subscribers_count' => (int) FunnelSubscriber::where('funnel_id', $funnel->id)
->where('status', 'completed')
->count(),
'created_at' => MCPHelper::toIso8601($funnel->created_at),
'updated_at' => MCPHelper::toIso8601($funnel->updated_at),
];
if (in_array('sequences', $include, true)) {
$sequences = FunnelHelper::getFunnelSequences($funnel, true);
$includeBodies = !empty($params['include_bodies']);
$data['sequences'] = self::formatSequences($sequences, $includeBodies);
}
if (in_array('report', $include, true)) {
$data['report'] = self::buildStepReport($funnel);
}
return $data;
}
/**
* Format funnel sequences for MCP. By default we strip large body
* fields out of `settings` (action_name=send_custom_email embeds an
* entire campaign payload including email_body). Pass include_bodies
* = true to get the full settings tree — review #7 (token bloat).
*/
private static function formatSequences($sequences, $includeBodies = false)
{
$out = [];
foreach ((array) $sequences as $seq) {
$row = is_object($seq) ? get_object_vars($seq) : (array) $seq;
$settings = $row['settings'] ?? [];
if (!$includeBodies) {
$settings = self::stripBodyFields($settings);
}
$out[] = [
'id' => isset($row['id']) ? (int) $row['id'] : null,
'type' => $row['type'] ?? null,
'action_name' => $row['action_name'] ?? null,
'settings' => $settings,
'delay' => $row['delay'] ?? 0,
'delay_unit' => $row['c_delay_unit'] ?? ($row['delay_unit'] ?? null),
'parent_id' => isset($row['parent_id']) ? (int) $row['parent_id'] : null,
];
}
return $out;
}
/**
* Recursively redact body / html fields. Replaces them with a marker so
* the agent knows the field exists and can re-fetch with include_bodies.
*
* Round-3 review B5: dropped the previous "only if >200 chars" gate —
* any stored body field gets stripped now, regardless of size, so the
* include_bodies=false contract is honored consistently. Rare to have a
* truly tiny body field anyway, and the marker is shorter than most
* email bodies.
*/
private static function stripBodyFields($value)
{
$bodyKeys = ['email_body', 'body', 'body_html', 'body_text'];
if (!is_array($value)) {
return $value;
}
foreach ($value as $k => $v) {
if (is_string($k) && in_array($k, $bodyKeys, true) && is_string($v)) {
$len = strlen($v);
$value[$k] = $len > 0
? '[truncated — re-fetch with include_bodies=true; ' . $len . ' chars]'
: '';
} elseif (is_array($v)) {
$value[$k] = self::stripBodyFields($v);
}
}
return $value;
}
private static function buildStepReport($funnel)
{
$stepCounts = FunnelSubscriber::where('funnel_id', $funnel->id)
->select(['last_sequence_id'])
->selectRaw('COUNT(id) as total')
->groupBy('last_sequence_id')
->get();
$steps = [];
foreach ($stepCounts as $row) {
if (!$row->last_sequence_id) {
continue;
}
$steps[] = [
'step_id' => (int) $row->last_sequence_id,
'total' => (int) $row->total,
];
}
return ['steps' => $steps];
}
private static function inProgressCount($funnelId)
{
return (int) FunnelSubscriber::where('funnel_id', $funnelId)
->whereIn('status', ['active', 'waiting'])
->count();
}
private static function triggerLabelMap()
{
$triggers = apply_filters('fluentcrm_funnel_triggers', []);
$map = [];
if (is_array($triggers)) {
foreach ($triggers as $key => $config) {
$map[$key] = $config['label'] ?? $key;
}
}
return $map;
}
// -----------------------------------------------------------------
// Read: list-funnel-subscribers (round-4 review P3 #11)
// -----------------------------------------------------------------
/**
* List the contacts currently in a funnel filtered by subscription
* status. Closes a real workflow gap: "this customer just upgraded —
* pull them out of trial-onboarding" requires knowing who's in the
* funnel first, and there was no way to find that without already
* knowing the contact id.
*/
public static function listFunnelSubscribers($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$allowedStatuses = ['active', 'waiting', 'completed', 'cancelled', 'skipped'];
$statuses = (array) ($params['statuses'] ?? ['active']);
$statuses = array_values(array_intersect(array_map('sanitize_key', $statuses), $allowedStatuses));
if (!$statuses) {
$statuses = ['active'];
}
MCPHelper::paginationFromInput($params);
$rows = FunnelSubscriber::with(['subscriber' => function ($q) {
$q->select(['id', 'email', 'first_name', 'last_name', 'status', 'contact_type']);
}])
->where('funnel_id', $funnelId)
->whereIn('status', $statuses)
->orderBy('id', 'DESC')
->paginate();
$items = [];
foreach ($rows->items() as $row) {
$sub = $row->subscriber;
if (!$sub) {
continue;
}
$items[] = [
'funnel_subscriber_id' => (int) $row->id,
'funnel_status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'last_executed_at' => MCPHelper::toIso8601($row->last_executed_time),
'next_execution_at' => MCPHelper::toIso8601($row->next_execution_time),
'enrolled_at' => MCPHelper::toIso8601($row->created_at),
'contact' => [
'id' => (int) $sub->id,
'email' => $sub->email,
'full_name' => trim((string) ($sub->first_name . ' ' . $sub->last_name)),
'status' => $sub->status,
'contact_type' => $sub->contact_type,
],
];
}
return [
'items' => $items,
'total' => (int) $rows->total(),
'page' => (int) $rows->currentPage(),
'per_page' => (int) $rows->perPage(),
'pages' => (int) $rows->lastPage(),
'funnel' => [
'id' => (int) $funnel->id,
'title' => $funnel->title,
],
'filtered_statuses' => $statuses,
];
}
// -----------------------------------------------------------------
// Write: update-contact-automation-status
// -----------------------------------------------------------------
public static function updateContactAutomationStatus($params)
{
$params = (array) $params;
$funnelId = (int) ($params['funnel_id'] ?? 0);
$action = sanitize_key((string) ($params['action'] ?? ''));
if (!$funnelId) {
return MCPHelper::error('invalid_param', __('funnel_id is required', 'fluent-crm'));
}
if (!in_array($action, ['resume', 'cancel', 'advance_now'], true)) {
// 'pause' was intentionally dropped — FluentCRM has no native
// paused funnel-subscriber state, and the previous mapping
// silently cancelled. Tell the agent what the alternative is.
if ($action === 'pause') {
return MCPHelper::error('not_supported', __('pause is not supported — FluentCRM has no paused state for funnel subscribers. Use cancel to stop processing (reversible from the UI), or wait for a real benchmark.', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
return MCPHelper::error('invalid_param', __('Invalid action', 'fluent-crm'), [
'allowed_actions' => ['resume', 'cancel', 'advance_now'],
]);
}
$contact = MCPHelper::resolveContact($params);
if (is_wp_error($contact)) {
return $contact;
}
$funnel = Funnel::find($funnelId);
if (!$funnel) {
return MCPHelper::error('not_found', __('Automation not found', 'fluent-crm'), ['funnel_id' => $funnelId]);
}
$row = FunnelSubscriber::where('funnel_id', $funnelId)
->where('subscriber_id', $contact->id)
->first();
if (!$row) {
return MCPHelper::error('not_found', __('Contact is not enrolled in this automation', 'fluent-crm'));
}
$previousStatus = $row->status;
if ($row->status === 'completed') {
return MCPHelper::error('not_supported', __('Automation is already completed for this contact', 'fluent-crm'), [
'status' => $row->status,
]);
}
if ($action === 'cancel') {
$row->status = 'cancelled';
$row->save();
} elseif ($action === 'resume') {
$row->status = 'active';
if (!$row->next_execution_time) {
$row->next_execution_time = gmdate('Y-m-d H:i:s', current_time('timestamp') + 60);
}
$row->save();
} elseif ($action === 'advance_now') {
$sequenceId = (int) ($params['advance_to_sequence_id'] ?? 0);
if (!$sequenceId) {
return MCPHelper::error('invalid_param', __('advance_to_sequence_id is required for advance_now', 'fluent-crm'));
}
$sequence = \FluentCrm\App\Models\FunnelSequence::where('id', $sequenceId)
->where('funnel_id', $funnelId)
->first();
if (!$sequence) {
return MCPHelper::error('not_found', __('Target sequence not found in this automation', 'fluent-crm'));
}
// If the contact is waiting on a benchmark, mark the benchmark as
// skipped so reports stay accurate (matches the controller's path).
if ($row->status === 'waiting') {
$benchmarkSeq = \FluentCrm\App\Models\FunnelSequence::find($row->next_sequence_id);
if ($benchmarkSeq) {
\FluentCrm\App\Models\FunnelMetric::updateOrCreate(
[
'funnel_id' => $funnelId,
'sequence_id' => $benchmarkSeq->id,
'subscriber_id' => $contact->id,
],
[
'benchmark_value' => 0,
'benchmark_currency' => 'USD',
'status' => 'skipped',
'notes' => __('Skipped via MCP advance_now', 'fluent-crm'),
]
);
\FluentCrm\App\Services\Funnel\FunnelHelper::changeFunnelSubSequenceStatus($row->id, $benchmarkSeq->id, 'skipped');
}
}
$prev = \FluentCrm\App\Models\FunnelSequence::where('funnel_id', $funnelId)
->where('sequence', '<', $sequence->sequence)
->orderBy('sequence', 'DESC')
->first();
$row->last_sequence_id = $prev ? $prev->id : 0;
$row->next_sequence_id = $sequence->id;
$row->next_sequence = $sequence->sequence;
$row->status = 'active';
$row->next_execution_time = current_time('mysql');
$row->save();
}
$row = FunnelSubscriber::find($row->id);
return [
'ok' => true,
'action' => $action,
'previous_status' => $previousStatus,
'current_status' => $row->status,
'funnel_subscriber' => [
'id' => (int) $row->id,
'funnel_id' => (int) $row->funnel_id,
'subscriber_id' => (int) $row->subscriber_id,
'status' => $row->status,
'next_sequence_id' => $row->next_sequence_id ? (int) $row->next_sequence_id : null,
'next_execution_time' => MCPHelper::toIso8601($row->next_execution_time),
],
];
}
}
@@ -0,0 +1,328 @@
<?php
namespace FluentCrm\App\Modules\MCP\Tools;
use FluentCrm\App\Models\Lists;
use FluentCrm\App\Models\Subscriber;
use FluentCrm\App\Models\Tag;
use FluentCrm\App\Modules\MCP\Helpers\MCPHelper;
use FluentCrm\App\Services\PermissionManager;
/**
* Tag/list management — round-3 review item #11.
*
* `manage-tag` and `manage-list` cover create, update, delete, and merge
* operations. Splitting create/update/delete into separate tools would
* triple the surface; the action enum keeps it compact while the
* `destructive` annotation tells MCP clients to confirm delete + merge.
*
* Merge semantics: re-pivot every subscriber attached to a `from` tag/list
* onto the `to` target, then delete the `from` rows. Idempotent — running
* the same merge twice no-ops on the second call (subscribers are already
* pivoted, sources already deleted).
*/
class SegmentTools
{
// -----------------------------------------------------------------
// manage-tag
// -----------------------------------------------------------------
public static function manageTag($params)
{
return self::manageSegment($params, 'tag');
}
// -----------------------------------------------------------------
// manage-list
// -----------------------------------------------------------------
public static function manageList($params)
{
return self::manageSegment($params, 'list');
}
private static function manageSegment($params, $kind)
{
$params = (array) $params;
$action = sanitize_key((string) ($params['action'] ?? ''));
if (!in_array($action, ['create', 'update', 'delete', 'merge'], true)) {
return MCPHelper::error('invalid_param', __('action must be one of: create, update, delete, merge', 'fluent-crm'));
}
// Permission gate. create/update need _cats; delete needs _cats_delete.
$needsDeleteCap = in_array($action, ['delete', 'merge'], true);
$cap = $needsDeleteCap ? 'fcrm_manage_contact_cats_delete' : 'fcrm_manage_contact_cats';
if (!PermissionManager::currentUserCan($cap)) {
return MCPHelper::error('forbidden', sprintf(
/* translators: %s: required capability */
__('This action requires the %s capability.', 'fluent-crm'),
$cap
), ['required' => $cap]);
}
switch ($action) {
case 'create':
return self::actionCreate($params, $kind);
case 'update':
return self::actionUpdate($params, $kind);
case 'delete':
return self::actionDelete($params, $kind);
case 'merge':
return self::actionMerge($params, $kind);
}
return MCPHelper::error('invalid_param', __('Unhandled action', 'fluent-crm'));
}
private static function actionCreate($params, $kind)
{
$title = trim((string) ($params['title'] ?? ''));
if ($title === '') {
return MCPHelper::error('invalid_param', __('title is required for create', 'fluent-crm'));
}
$slug = isset($params['slug']) && $params['slug'] !== ''
? sanitize_title((string) $params['slug'])
: sanitize_title($title);
$description = sanitize_textarea_field((string) ($params['description'] ?? ''));
$existing = self::lookupByTitleOrSlug($title, $slug, $kind);
if ($existing) {
return MCPHelper::error('contact_exists', sprintf(
/* translators: 1: kind (tag/list), 2: matched id */
__('A %1$s with that title or slug already exists (id %2$d). Use update or merge to change it.', 'fluent-crm'),
$kind,
(int) $existing->id
), ['existing_id' => (int) $existing->id]);
}
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
$row = $modelClass::create([
'title' => sanitize_text_field($title),
'slug' => $slug,
'description' => $description,
]);
do_action(self::createdHook($kind), $row);
return [
'ok' => true,
'action' => 'create',
'kind' => $kind,
$kind => self::format($row),
'note' => sprintf(
/* translators: 1: kind, 2: title */
__('%1$s "%2$s" created. No subscribers are attached yet.', 'fluent-crm'),
ucfirst($kind),
$row->title
),
];
}
private static function actionUpdate($params, $kind)
{
$id = (int) ($params[$kind . '_id'] ?? 0);
$row = $id ? self::find($id, $kind) : null;
if (!$row) {
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
}
$changes = [];
if (isset($params['title']) && $params['title'] !== '' && $params['title'] !== $row->title) {
$changes['title'] = ['from' => $row->title, 'to' => sanitize_text_field((string) $params['title'])];
$row->title = $changes['title']['to'];
}
if (isset($params['slug']) && $params['slug'] !== '') {
$newSlug = sanitize_title((string) $params['slug']);
if ($newSlug !== $row->slug) {
$changes['slug'] = ['from' => $row->slug, 'to' => $newSlug];
$row->slug = $newSlug;
}
}
if (array_key_exists('description', $params)) {
$newDesc = sanitize_textarea_field((string) $params['description']);
if ($newDesc !== $row->description) {
$changes['description'] = ['from' => $row->description, 'to' => $newDesc];
$row->description = $newDesc;
}
}
if (empty($changes)) {
return [
'ok' => true,
'action' => 'update',
'kind' => $kind,
$kind => self::format($row),
'note' => __('No changes — provided fields matched the current values.', 'fluent-crm'),
];
}
$row->save();
return [
'ok' => true,
'action' => 'update',
'kind' => $kind,
$kind => self::format($row),
'changes' => $changes,
];
}
private static function actionDelete($params, $kind)
{
$id = (int) ($params[$kind . '_id'] ?? 0);
$row = $id ? self::find($id, $kind) : null;
if (!$row) {
return MCPHelper::error('not_found', sprintf(__('%s not found', 'fluent-crm'), ucfirst($kind)), [$kind . '_id' => $id]);
}
$force = !empty($params['force']);
$attachedCount = self::attachedSubscriberCount($row, $kind);
if ($attachedCount > 0 && !$force) {
return MCPHelper::error('not_supported', sprintf(
/* translators: 1: kind, 2: count */
__('%1$s has %2$d subscribers attached. Pass force=true to delete anyway, or merge into another %1$s first.', 'fluent-crm'),
ucfirst($kind),
$attachedCount
), [
'attached_subscribers' => $attachedCount,
'force_required' => true,
]);
}
$deletedId = (int) $row->id;
$deletedTitle = (string) $row->title;
$row->delete();
do_action(self::deletedHook($kind), $deletedId);
return [
'ok' => true,
'action' => 'delete',
'kind' => $kind,
'deleted_id' => $deletedId,
'deleted_title' => $deletedTitle,
'detached_subscribers' => $attachedCount,
'note' => $attachedCount > 0
? __('Deleted with subscribers attached — pivot rows are orphaned and cleaned up by the cleanup hook.', 'fluent-crm')
: __('Deleted. No subscribers were attached.', 'fluent-crm'),
];
}
private static function actionMerge($params, $kind)
{
$fromIds = isset($params['from_' . $kind . '_ids']) ? (array) $params['from_' . $kind . '_ids'] : [];
$fromIds = array_values(array_unique(array_filter(array_map('intval', $fromIds))));
$toId = (int) ($params['to_' . $kind . '_id'] ?? 0);
if (!$toId) {
return MCPHelper::error('invalid_param', __('to_*_id is required for merge', 'fluent-crm'));
}
if (!$fromIds) {
return MCPHelper::error('invalid_param', __('from_*_ids must be a non-empty array of ids', 'fluent-crm'));
}
if (in_array($toId, $fromIds, true)) {
return MCPHelper::error('invalid_param', __('to_*_id cannot also be in from_*_ids', 'fluent-crm'));
}
$to = self::find($toId, $kind);
if (!$to) {
return MCPHelper::error('not_found', sprintf(__('Target %s not found', 'fluent-crm'), $kind), ['to_id' => $toId]);
}
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
$fromRows = $modelClass::whereIn('id', $fromIds)->get();
$foundFromIds = $fromRows->pluck('id')->map('intval')->toArray();
$missingFromIds = array_values(array_diff($fromIds, $foundFromIds));
// Re-pivot subscribers attached to the `from` set onto the `to` target.
$attachedCount = 0;
foreach ($fromRows as $fromRow) {
$count = self::attachedSubscriberCount($fromRow, $kind);
$attachedCount += $count;
}
$repivoted = 0;
foreach ($fromRows as $fromRow) {
$subscribers = self::attachedSubscribers($fromRow, $kind);
foreach ($subscribers as $sub) {
if ($kind === 'list') {
$sub->attachLists([$to->id]);
$sub->detachLists([$fromRow->id]);
} else {
$sub->attachTags([$to->id]);
$sub->detachTags([$fromRow->id]);
}
$repivoted++;
}
}
// Delete the source rows.
foreach ($fromRows as $fromRow) {
$fromId = (int) $fromRow->id;
$fromRow->delete();
do_action(self::deletedHook($kind), $fromId);
}
return [
'ok' => true,
'action' => 'merge',
'kind' => $kind,
'merged_from' => $foundFromIds,
'merged_into' => self::format($to),
'subscribers_repivoted' => $repivoted,
'subscribers_seen' => $attachedCount,
'missing_from_ids' => $missingFromIds,
'note' => __('Each subscriber attached to a "from" target is now attached to "to" and the "from" rows are deleted. Re-running this merge with the same ids is a safe no-op.', 'fluent-crm'),
];
}
// -----------------------------------------------------------------
// helpers
// -----------------------------------------------------------------
private static function find($id, $kind)
{
return $kind === 'list' ? Lists::find($id) : Tag::find($id);
}
private static function lookupByTitleOrSlug($title, $slug, $kind)
{
$modelClass = $kind === 'list' ? Lists::class : Tag::class;
return $modelClass::where('title', $title)->orWhere('slug', $slug)->first();
}
private static function format($row)
{
return [
'id' => (int) $row->id,
'title' => $row->title,
'slug' => $row->slug,
'description' => $row->description ?? '',
];
}
private static function attachedSubscriberCount($row, $kind)
{
return (int) ($kind === 'list'
? $row->subscribers()->count()
: $row->subscribers()->count());
}
private static function attachedSubscribers($row, $kind)
{
return $kind === 'list'
? $row->subscribers()->get()
: $row->subscribers()->get();
}
private static function createdHook($kind)
{
return $kind === 'list' ? 'fluent_crm/list_created' : 'fluent_crm/tag_created';
}
private static function deletedHook($kind)
{
return $kind === 'list' ? 'fluent_crm/list_deleted' : 'fluent_crm/tag_deleted';
}
}