That's the fix. The issue was that `renderPagination` builds onclick handlers using `loadFunction.name`, but the anonymous arrow function `(p) => loadEmails(p)` has an empty `.name`, so clicking page 2/3/etc generated `onclick="(2)"` which just evaluates to `2` and does nothing.

Now it passes `loadEmails` directly (same pattern as contacts uses `loadContacts`), so pagination buttons will correctly call `loadEmails(2)`, `loadEmails(3)`, etc. The filter values are read fresh from the DOM each time `loadEmails` runs, so filters will persist across page changes.

Try refreshing and clicking page 2/3 — it should work now.


<ide_opened_file>The user opened the file /var/www/mssql.danmarcrm.com/dev1/extension2v1/aiapi/api_opus/config/api_keys.php in the IDE. This may or may not be related to the current task.</ide_opened_file>

what about searching by domain name?


Let me check how the thread search works — you mentioned searching "delta-engineering.be" only returned 1 result.


359:        case 'threads':
360-            $contactId = (int)($_GET['contact'] ?? 0);
361-            $search = trim($_GET['search'] ?? '');
362-            $page = max(1, (int)($_GET['page'] ?? 1));
363-            $limit = min(100, max(1, (int)($_GET['limit'] ?? 20)));
364-            $offset = ($page - 1) * $limit;
365-            $hasBusiness = isset($_GET['has_business']) && $_GET['has_business'];
366-            $threadSort = $_GET['sort'] ?? 'last_date';
367-            $threadOrder = strtoupper($_GET['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';
368-            $threadContactName = trim($_GET['contact_name'] ?? '');
369-            $threadDateFrom = $_GET['date_from'] ?? '';
370-            $threadDateTo = $_GET['date_to'] ?? '';
371-            $threadStatus = $_GET['status'] ?? '';
372-
373-            // Use QueryAdapter for automatic MySQL/MongoDB routing
374-            $adapter = QueryAdapter::getInstance();
375-            $result = $adapter->getThreads(
376-                $contactId ?: null,
377-                $search,
378-                $limit,
379-                $offset,
380-                $threadSort,
381-                $threadOrder,
382-                $threadContactName,
383-                $threadDateFrom,
384-                $threadDateTo,
385-                $threadStatus
386-            );
387-
388-            / Note: has_business filtering is handled by frontend due to raw_analysis being stored as JSON string
389-            / Future: migrate raw_analysis to proper BSON objects for better querying
390-
391-            jsonResponse([
392-                'threads' => $result['threads'],
393-                'pagination' => [
394-                    'page' => $page,
395-                    'limit' => $limit,
396-                    'total' => $result['total'],
397-                    'pages' => ceil($result['total'] / $limit)
398-                ]
399-            ]);


615:    private function getThreadsMongo(


   615→    private function getThreadsMongo(
   616→        ?int $contactId,
   617→        string $search,
   618→        int $limit,
   619→        int $offset,
   620→        string $sort = 'last_date',
   621→        string $order = 'DESC',
   622→        string $contactName = '',
   623→        string $dateFrom = '',
   624→        string $dateTo = '',
   625→        string $status = ''
   626→    ): array {
   627→        $mongo = MongoDatabase::getInstance();
   628→        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';
   629→
   630→        // Build match filter
   631→        $matchFilter = [
   632→            'thread_id' => ['$ne' => null, '$ne' => '']
   633→        ];
   634→
   635→        if ($contactId) {
   636→            $matchFilter['contact_id'] = $contactId;
   637→        }
   638→
   639→        if ($search) {
   640→            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
   641→        }
   642→
   643→        // Date range filter on email dates
   644→        if ($dateFrom || $dateTo) {
   645→            $dateFilter = [];
   646→            if ($dateFrom) $dateFilter['$gte'] = $dateFrom;
   647→            if ($dateTo) $dateFilter['$lte'] = $dateTo . ' 23:59:59';
   648→            $matchFilter['date'] = $dateFilter;
   649→        }
   650→
   651→        // Contact name filter: pre-query contact IDs
   652→        if ($contactName) {
   653→            $matchingContacts = $mongo->findAll('contacts', [
   654→                'name' => ['$regex' => $contactName, '$options' => 'i']
   655→            ], ['projection' => ['id' => 1]]);
   656→            $matchingIds = array_column($matchingContacts, 'id');
   657→            if (!empty($matchingIds)) {
   658→                $matchFilter['contact_id'] = ['$in' => $matchingIds];
   659→            } else {
   660→                return ['threads' => [], 'total' => 0];
   661→            }
   662→        }
   663→
   664→        // Count distinct threads for total
   665→        if ($status) {
   666→            // Need full pipeline to filter by analysis status
   667→            $countPipeline = [
   668→                ['$match' => $matchFilter],
   669→                ['$group' => [
   670→                    '_id' => '$thread_id',
   671→                    'email_count' => ['$sum' => 1]
   672→                ]],
   673→                ['$lookup' => [
   674→                    'from' => 'thread_analysis',
   675→                    'localField' => '_id',
   676→                    'foreignField' => 'thread_id',
   677→                    'as' => 'analysis'
   678→                ]],
   679→                ['$addFields' => [
   680→                    'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]]
   681→                ]]
   682→            ];
   683→            if ($status === 'analyzed') {
   684→                $countPipeline[] = ['$match' => ['is_analyzed' => true]];
   685→            } elseif ($status === 'not_analyzed') {
   686→                $countPipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
   687→            }
   688→            $countPipeline[] = ['$count' => 'total'];
   689→            $countResult = $mongo->aggregate('emails', $countPipeline);
   690→            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
   691→        } else {
   692→            $countPipeline = [
   693→                ['$match' => $matchFilter],
   694→                ['$group' => ['_id' => '$thread_id']],
   695→                ['$count' => 'total']
   696→            ];
   697→            $countResult = $mongo->aggregate('emails', $countPipeline);
   698→            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
   699→        }
   700→
   701→        // Get threads with aggregated data
   702→        // Collect all contacts per thread to find the most relevant one (not just $max)
   703→        $pipeline = [
   704→            ['$match' => $matchFilter],
   705→            ['$group' => [
   706→                '_id' => '$thread_id',
   707→                'first_email_id' => ['$min' => '$id'],
   708→                'last_email_id' => ['$max' => '$id'],
   709→                'subject' => ['$first' => '$subject'],
   710→                'first_date' => ['$min' => '$date'],
   711→                'last_date' => ['$max' => '$date'],
   712→                'email_count' => ['$sum' => 1],
   713→                'inbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'inbound']], 1, 0]]],
   714→                'outbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'outbound']], 1, 0]]],
   715→                'all_contacts' => ['$push' => [
   716→                    'cid' => '$contact_id',
   717→                    'from' => '$from_email',
   718→                    'to' => '$to_email',
   719→                    'dir' => '$direction'
   720→                ]]
   721→            ]],
   722→            ['$lookup' => [
   723→                'from' => 'thread_analysis',
   724→                'localField' => '_id',
   725→                'foreignField' => 'thread_id',
   726→                'as' => 'analysis'
   727→            ]],
   728→            ['$project' => [
   729→                '_id' => 0,
   730→                'thread_id' => '$_id',
   731→                'first_email_id' => 1,
   732→                'last_email_id' => 1,
   733→                'subject' => 1,
   734→                'first_date' => 1,

    private function getThreadsMongo(
        ?int $contactId,
        string $search,
        int $limit,
        int $offset,
        string $sort = 'last_date',
        string $order = 'DESC',
        string $contactName = '',
        string $dateFrom = '',
        string $dateTo = '',
        string $status = ''
    ): array {
        $mongo = MongoDatabase::getInstance();
        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';

        // Build match filter
        $matchFilter = [
            'thread_id' => ['$ne' => null, '$ne' => '']
        ];

        if ($contactId) {
            $matchFilter['contact_id'] = $contactId;
        }

        if ($search) {
            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
        }

        // Date range filter on email dates
        if ($dateFrom || $dateTo) {
            $dateFilter = [];
            if ($dateFrom) $dateFilter['$gte'] = $dateFrom;
            if ($dateTo) $dateFilter['$lte'] = $dateTo . ' 23:59:59';
            $matchFilter['date'] = $dateFilter;
        }

        // Contact name filter: pre-query contact IDs
        if ($contactName) {
            $matchingContacts = $mongo->findAll('contacts', [
                'name' => ['$regex' => $contactName, '$options' => 'i']
            ], ['projection' => ['id' => 1]]);
            $matchingIds = array_column($matchingContacts, 'id');
            if (!empty($matchingIds)) {
                $matchFilter['contact_id'] = ['$in' => $matchingIds];
            } else {
                return ['threads' => [], 'total' => 0];
            }
        }

        // Count distinct threads for total
        if ($status) {
            // Need full pipeline to filter by analysis status
            $countPipeline = [
                ['$match' => $matchFilter],
                ['$group' => [
                    '_id' => '$thread_id',
                    'email_count' => ['$sum' => 1]
                ]],
                ['$lookup' => [
                    'from' => 'thread_analysis',
                    'localField' => '_id',
                    'foreignField' => 'thread_id',
                    'as' => 'analysis'
                ]],
                ['$addFields' => [
                    'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]]
                ]]
            ];
            if ($status === 'analyzed') {
                $countPipeline[] = ['$match' => ['is_analyzed' => true]];
            } elseif ($status === 'not_analyzed') {
                $countPipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
            }
            $countPipeline[] = ['$count' => 'total'];
            $countResult = $mongo->aggregate('emails', $countPipeline);
            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
        } else {
            $countPipeline = [
                ['$match' => $matchFilter],
                ['$group' => ['_id' => '$thread_id']],
                ['$count' => 'total']
            ];
            $countResult = $mongo->aggregate('emails', $countPipeline);
            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
        }

        // Get threads with aggregated data
        // Collect all contacts per thread to find the most relevant one (not just $max)
        $pipeline = [
            ['$match' => $matchFilter],
            ['$group' => [
                '_id' => '$thread_id',
                'first_email_id' => ['$min' => '$id'],
                'last_email_id' => ['$max' => '$id'],
                'subject' => ['$first' => '$subject'],
                'first_date' => ['$min' => '$date'],
                'last_date' => ['$max' => '$date'],
                'email_count' => ['$sum' => 1],
                'inbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'inbound']], 1, 0]]],
                'outbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'outbound']], 1, 0]]],
                'all_contacts' => ['$push' => [
                    'cid' => '$contact_id',
                    'from' => '$from_email',
                    'to' => '$to_email',
                    'dir' => '$direction'
                ]]
            ]],
            ['$lookup' => [
                'from' => 'thread_analysis',
                'localField' => '_id',
                'foreignField' => 'thread_id',
                'as' => 'analysis'
            ]],
            ['$project' => [
                '_id' => 0,
                'thread_id' => '$_id',
                'first_email_id' => 1,
                'last_email_id' => 1,
                'subject' => 1,
                'first_date' => 1,


   734→                'first_date' => 1,
   735→                'last_date' => 1,
   736→                'email_count' => 1,
   737→                'inbound_count' => 1,
   738→                'outbound_count' => 1,
   739→                'all_contacts' => 1,
   740→                'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]],
   741→                'needs_reanalysis' => ['$arrayElemAt' => ['$analysis.needs_reanalysis', 0]]
   742→            ]],
   743→        ];
   744→
   745→        // Status filter (after analysis lookup)
   746→        if ($status === 'analyzed') {
   747→            $pipeline[] = ['$match' => ['is_analyzed' => true]];
   748→        } elseif ($status === 'not_analyzed') {
   749→            $pipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
   750→        }
   751→
   752→        // Dynamic sort
   753→        $allowedSorts = ['last_date', 'first_date', 'email_count', 'subject'];
   754→        $sortField = in_array($sort, $allowedSorts) ? $sort : 'last_date';
   755→        $sortDir = strtoupper($order) === 'ASC' ? 1 : -1;
   756→
   757→        $pipeline[] = ['$sort' => [$sortField => $sortDir]];
   758→        $pipeline[] = ['$skip' => $offset];
   759→        $pipeline[] = ['$limit' => $limit];
   760→
   761→        $threads = $mongo->aggregate('emails', $pipeline);
   762→
   763→        // Post-process: find the most frequent external contact per thread
   764→        $contactIds = [];
   765→        foreach ($threads as &$thread) {
   766→            $contactCounts = [];
   767→
   768→            foreach ($thread['all_contacts'] ?? [] as $c) {
   769→                $cid = $c['cid'] ?? null;
   770→                $fromEmail = strtolower($c['from'] ?? '');
   771→                $toEmail = strtolower($c['to'] ?? '');
   772→                $dir = $c['dir'] ?? '';
   773→
   774→                if (!$cid) continue;
   775→
   776→                // For inbound: from_email is external sender (weight 2x)
   777→                if ($dir === 'inbound' && $fromEmail && $fromEmail !== $userEmail) {
   778→                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 2;
   779→                }
   780→                // For outbound: to_email is external recipient (weight 1x)
   781→                elseif ($dir === 'outbound' && $toEmail && $toEmail !== $userEmail) {
   782→                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 1;
   783→                }
   784→            }
   785→
   786→            // Pick the most frequent contact
   787→            if (!empty($contactCounts)) {
   788→                arsort($contactCounts);
   789→                $thread['contact_id'] = (int)array_key_first($contactCounts);
   790→            } else {
   791→                // Fallback: first non-null contact_id
   792→                $thread['contact_id'] = null;
   793→                foreach ($thread['all_contacts'] ?? [] as $c) {
   794→                    if (!empty($c['cid'])) {
   795→                        $thread['contact_id'] = (int)$c['cid'];
   796→                        break;
   797→                    }
   798→                }
   799→            }
   800→
   801→            if ($thread['contact_id']) {
   802→                $contactIds[] = $thread['contact_id'];
   803→            }
   804→
   805→            unset($thread['all_contacts']);
   806→        }
   807→        unset($thread);
   808→
   809→        // Batch lookup contact names
   810→        $contactNames = [];
   811→        if (!empty($contactIds)) {
   812→            $contacts = $mongo->findAll('contacts', [
   813→                'id' => ['$in' => array_values(array_unique($contactIds))]

                'first_date' => 1,
                'last_date' => 1,
                'email_count' => 1,
                'inbound_count' => 1,
                'outbound_count' => 1,
                'all_contacts' => 1,
                'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]],
                'needs_reanalysis' => ['$arrayElemAt' => ['$analysis.needs_reanalysis', 0]]
            ]],
        ];

        // Status filter (after analysis lookup)
        if ($status === 'analyzed') {
            $pipeline[] = ['$match' => ['is_analyzed' => true]];
        } elseif ($status === 'not_analyzed') {
            $pipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
        }

        // Dynamic sort
        $allowedSorts = ['last_date', 'first_date', 'email_count', 'subject'];
        $sortField = in_array($sort, $allowedSorts) ? $sort : 'last_date';
        $sortDir = strtoupper($order) === 'ASC' ? 1 : -1;

        $pipeline[] = ['$sort' => [$sortField => $sortDir]];
        $pipeline[] = ['$skip' => $offset];
        $pipeline[] = ['$limit' => $limit];

        $threads = $mongo->aggregate('emails', $pipeline);

        // Post-process: find the most frequent external contact per thread
        $contactIds = [];
        foreach ($threads as &$thread) {
            $contactCounts = [];

            foreach ($thread['all_contacts'] ?? [] as $c) {
                $cid = $c['cid'] ?? null;
                $fromEmail = strtolower($c['from'] ?? '');
                $toEmail = strtolower($c['to'] ?? '');
                $dir = $c['dir'] ?? '';

                if (!$cid) continue;

                // For inbound: from_email is external sender (weight 2x)
                if ($dir === 'inbound' && $fromEmail && $fromEmail !== $userEmail) {
                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 2;
                }
                // For outbound: to_email is external recipient (weight 1x)
                elseif ($dir === 'outbound' && $toEmail && $toEmail !== $userEmail) {
                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 1;
                }
            }

            // Pick the most frequent contact
            if (!empty($contactCounts)) {
                arsort($contactCounts);
                $thread['contact_id'] = (int)array_key_first($contactCounts);
            } else {
                // Fallback: first non-null contact_id
                $thread['contact_id'] = null;
                foreach ($thread['all_contacts'] ?? [] as $c) {
                    if (!empty($c['cid'])) {
                        $thread['contact_id'] = (int)$c['cid'];
                        break;
                    }
                }
            }

            if ($thread['contact_id']) {
                $contactIds[] = $thread['contact_id'];
            }

            unset($thread['all_contacts']);
        }
        unset($thread);

        // Batch lookup contact names
        $contactNames = [];
        if (!empty($contactIds)) {
            $contacts = $mongo->findAll('contacts', [
                'id' => ['$in' => array_values(array_unique($contactIds))]


I see the issue now. The search on line 639-641 only searches the `subject` field:

```php
if ($search) {
    $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
}
```

When searching "delta-engineering.be", it only matches threads whose **subject** contains that domain name. It doesn't search email addresses (`from_email`, `to_email`), which is where the domain would typically appear.

The search needs to also look at `from_email` and `to_email` fields. Let me fix this to use an `$or` query that searches subject, from_email, and to_email.


Found the issue. The thread search on line 639-641 only searches the `subject` field:

```php
$matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
```

So searching "delta-engineering.be" only finds threads where that domain appears in the **subject line**, not in the email addresses. It needs to also search `from_email` and `to_email`.


Edit

        if ($search) {
            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
        }

        if ($search) {
            $matchFilter['$or'] = [
                ['subject' => ['$regex' => $search, '$options' => 'i']],
                ['from_email' => ['$regex' => $search, '$options' => 'i']],
                ['to_email' => ['$regex' => $search, '$options' => 'i']]
            ];
        }


The file /var/www/mssql.danmarcrm.com/dev1/extension2v1/src/QueryAdapter.php has been updated successfully.

        if ($search) {
            $matchFilter['$or'] = [
                ['subject' => ['$regex' => $search, '$options' => 'i']],
                ['from_email' => ['$regex' => $search, '$options' => 'i']],
                ['to_email' => ['$regex' => $search, '$options' => 'i']]
            ];
        }

<?php

namespace EmailIntelligence;

/**
 * QueryAdapter - Automatically routes queries to MySQL or MongoDB
 *
 * This class provides a unified interface that works with both databases.
 * When MySQL is enabled, it uses SQL queries.
 * When MySQL is disabled, it routes to MongoRepository equivalents.
 *
 * Usage:
 *   $adapter = QueryAdapter::getInstance();
 *   $contacts = $adapter->getContactsWithProfiles($search, $limit, $offset, $sort);
 */
class QueryAdapter
{
    private static ?QueryAdapter $instance = null;
    private Database $db;
    private ?MongoRepository $mongoRepo = null;
    private bool $useMongo = true;

    private function __construct()
    {
        $this->db = Database::getInstance();

        // Use MongoDB if MySQL is disabled
        $this->useMongo = !defined('MYSQL_ENABLED') || !MYSQL_ENABLED;

        if ($this->useMongo) {
            $this->mongoRepo = new MongoRepository();
        }
    }

    public static function getInstance(): QueryAdapter
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Check which database is being used
     */
    public function getActiveDatabase(): string
    {
        return $this->useMongo ? 'mongodb' : 'mysql';
    }

    // =========================================================================
    // CONTACTS
    // =========================================================================

    /**
     * Get contacts with profiles (paginated)
     *
     * @param string $search Search term (name, email, company)
     * @param int $limit Results per page
     * @param int $offset Pagination offset
     * @param string $sortColumn Column to sort by
     * @param string $sortOrder ASC or DESC
     * @return array ['contacts' => [...], 'total' => int]
     */
    public function getContactsWithProfiles(
        string $search = '',
        int $limit = 50,
        int $offset = 0,
        string $sortColumn = 'name',
        string $sortOrder = 'ASC',
        string $type = '',
        string $dateFrom = '',
        string $dateTo = ''
    ): array {
        if ($this->useMongo) {
            return $this->getContactsWithProfilesMongo($search, $limit, $offset, $sortColumn, $sortOrder, $type, $dateFrom, $dateTo);
        }

        return $this->getContactsWithProfilesSQL($search, $limit, $offset, $sortColumn, $sortOrder);
    }

    private function getContactsWithProfilesSQL(
        string $search,
        int $limit,
        int $offset,
        string $sortColumn,
        string $sortOrder
    ): array {
        $whereClause = '1=1';
        $params = [];

        if ($search) {
            $whereClause = "(c.name LIKE ? OR c.email LIKE ? OR c.company LIKE ?)";
            $searchParam = "%{$search}%";
            $params = [$searchParam, $searchParam, $searchParam];
        }

        // Validate sort column
        $allowedColumns = ['name', 'email', 'company', 'created_at', 'reliability_score', 'risk_level'];
        if (!in_array($sortColumn, $allowedColumns)) {
            $sortColumn = 'name';
        }
        $sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC';

        $total = $this->db->fetchColumn(
            "SELECT COUNT(*) FROM contacts c WHERE {$whereClause}",
            $params
        );

        $contacts = $this->db->fetchAll(
            "SELECT c.*,
                cp.relationship_health, cp.reliability_score, cp.risk_level,
                cp.pricing_sensitivity, cp.ai_recommendations
             FROM contacts c
             LEFT JOIN contact_profiles cp ON c.id = cp.contact_id
             WHERE {$whereClause}
             ORDER BY {$sortColumn} {$sortOrder}
             LIMIT ? OFFSET ?",
            array_merge($params, [$limit, $offset])
        );

        return ['contacts' => $contacts, 'total' => (int)$total];
    }

    private function getContactsWithProfilesMongo(
        string $search,
        int $limit,
        int $offset,
        string $sortColumn,
        string $sortOrder,
        string $type = '',
        string $dateFrom = '',
        string $dateTo = ''
    ): array {
        $filter = [];
        if ($search) {
            $filter['$or'] = [
                ['name' => ['$regex' => $search, '$options' => 'i']],
                ['email' => ['$regex' => $search, '$options' => 'i']],
                ['company' => ['$regex' => $search, '$options' => 'i']]
            ];
        }
        if ($type) {
            $filter['type'] = $type;
        }

        // Get total count
        $mongo = MongoDatabase::getInstance();

        // Map sort column to MongoDB field
        $sortDir = strtoupper($sortOrder) === 'DESC' ? -1 : 1;

        // Get contacts with profiles via aggregation
        $pipeline = [];

        if (!empty($filter)) {
            $pipeline[] = ['$match' => $filter];
        }

        $pipeline[] = ['$lookup' => [
            'from' => 'contact_profiles',
            'localField' => 'id',
            'foreignField' => 'contact_id',
            'as' => 'profile'
        ]];

        $pipeline[] = ['$unwind' => [
            'path' => '$profile',
            'preserveNullAndEmptyArrays' => true
        ]];

        // Date range filter on last_contact (already stored on contact document)
        if ($dateFrom || $dateTo) {
            $dateFilter = [];
            if ($dateFrom) $dateFilter['$gte'] = $dateFrom;
            if ($dateTo) $dateFilter['$lte'] = $dateTo . ' 23:59:59';
            $pipeline[] = ['$match' => ['last_contact' => $dateFilter]];
        }

        // Count after all filters (need separate pipeline for accurate count with date filters)
        if ($dateFrom || $dateTo) {
            $countPipeline = $pipeline;
            $countPipeline[] = ['$count' => 'total'];
            $countResult = $mongo->aggregate('contacts', $countPipeline);
            $total = $countResult[0]['total'] ?? 0;
        } else {
            $total = $mongo->count('contacts', $filter);
        }

        $pipeline[] = ['$sort' => [$sortColumn => $sortDir]];
        $pipeline[] = ['$skip' => $offset];
        $pipeline[] = ['$limit' => $limit];

        $results = $mongo->aggregate('contacts', $pipeline);

        // Flatten profile fields
        $contacts = array_map(function($doc) {
            if (isset($doc['profile'])) {
                $doc['relationship_health'] = $doc['profile']['relationship_health'] ?? null;
                $doc['reliability_score'] = $doc['profile']['reliability_score'] ?? null;
                $doc['risk_level'] = $doc['profile']['risk_level'] ?? null;
                $doc['pricing_sensitivity'] = $doc['profile']['pricing_sensitivity'] ?? null;
                $doc['ai_recommendations'] = $doc['profile']['ai_recommendations'] ?? null;
                unset($doc['profile']);
            }
            return $doc;
        }, $results);

        return ['contacts' => $contacts, 'total' => $total];
    }

    /**
     * Get single contact with full details
     */
    public function getContactWithDetails(int $contactId): ?array
    {
        if ($this->useMongo) {
            return $this->getContactWithDetailsMongo($contactId);
        }

        return $this->getContactWithDetailsSQL($contactId);
    }

    private function getContactWithDetailsSQL(int $contactId): ?array
    {
        $contact = $this->db->fetchOne(
            "SELECT c.*, cp.*
             FROM contacts c
             LEFT JOIN contact_profiles cp ON c.id = cp.contact_id
             WHERE c.id = ?",
            [$contactId]
        );

        if (!$contact) return null;

        // Get recent emails
        $recentEmails = $this->db->fetchAll(
            "SELECT e.id, e.subject, e.date, e.direction, e.snippet,
                    ea.sentiment_label, ea.urgency_level, ea.summary
             FROM emails e
             LEFT JOIN email_analysis ea ON e.id = ea.email_id
             WHERE e.contact_id = ?
             ORDER BY e.date DESC
             LIMIT 20",
            [$contactId]
        );

        // Get quotes
        $quotes = $this->db->fetchAll(
            "SELECT * FROM quotes WHERE contact_id = ? ORDER BY quote_date DESC",
            [$contactId]
        );

        // Parse JSON fields
        $jsonFields = ['personality_traits', 'detected_inconsistencies', 'risk_factors',
                      'best_approach', 'topics_to_avoid', 'quote_history'];
        foreach ($jsonFields as $field) {
            if (isset($contact[$field]) && is_string($contact[$field])) {
                $contact[$field] = json_decode($contact[$field], true);
            }
        }

        return [
            'contact' => $contact,
            'recent_emails' => $recentEmails,
            'quotes' => $quotes
        ];
    }

    private function getContactWithDetailsMongo(int $contactId): ?array
    {
        $mongo = MongoDatabase::getInstance();

        // Get contact with profile
        $pipeline = [
            ['$match' => ['id' => $contactId]],
            ['$lookup' => [
                'from' => 'contact_profiles',
                'localField' => 'id',
                'foreignField' => 'contact_id',
                'as' => 'profile'
            ]],
            ['$unwind' => ['path' => '$profile', 'preserveNullAndEmptyArrays' => true]]
        ];

        $results = $mongo->aggregate('contacts', $pipeline);
        if (empty($results)) return null;

        $contact = $results[0];

        // Flatten profile
        if (isset($contact['profile'])) {
            foreach ($contact['profile'] as $key => $value) {
                if ($key !== '_id' && $key !== 'contact_id') {
                    $contact[$key] = $value;
                }
            }
            unset($contact['profile']);
        }

        // Get recent emails with analysis
        $emailPipeline = [
            ['$match' => ['contact_id' => $contactId]],
            ['$lookup' => [
                'from' => 'email_analysis',
                'localField' => 'id',
                'foreignField' => 'email_id',
                'as' => 'analysis'
            ]],
            ['$unwind' => ['path' => '$analysis', 'preserveNullAndEmptyArrays' => true]],
            ['$sort' => ['date' => -1]],
            ['$limit' => 20],
            ['$project' => [
                'id' => 1,
                'subject' => 1,
                'date' => 1,
                'direction' => 1,
                'snippet' => 1,
                'sentiment_label' => '$analysis.sentiment_label',
                'urgency_level' => '$analysis.urgency_level',
                'summary' => '$analysis.summary'
            ]]
        ];

        $recentEmails = $mongo->aggregate('emails', $emailPipeline);

        // Get quotes
        $quotes = $mongo->findAll('quotes',
            ['contact_id' => $contactId],
            ['sort' => ['quote_date' => -1]]
        );

        return [
            'contact' => $contact,
            'recent_emails' => $recentEmails,
            'quotes' => $quotes
        ];
    }

    // =========================================================================
    // EMAILS
    // =========================================================================

    /**
     * Get emails with analysis for a contact
     */
    public function getEmailsWithAnalysis(
        int $contactId,
        int $limit = 50,
        int $offset = 0,
        string $sortColumn = 'date',
        string $sortOrder = 'DESC'
    ): array {
        if ($this->useMongo) {
            return $this->getEmailsWithAnalysisMongo($contactId, $limit, $offset, $sortColumn, $sortOrder);
        }

        return $this->getEmailsWithAnalysisSQL($contactId, $limit, $offset, $sortColumn, $sortOrder);
    }

    private function getEmailsWithAnalysisSQL(
        int $contactId,
        int $limit,
        int $offset,
        string $sortColumn,
        string $sortOrder
    ): array {
        $total = $this->db->fetchColumn(
            "SELECT COUNT(*) FROM emails WHERE contact_id = ?",
            [$contactId]
        );

        $sortOrder = strtoupper($sortOrder) === 'DESC' ? 'DESC' : 'ASC';

        $emails = $this->db->fetchAll(
            "SELECT e.*, ea.sentiment_label, ea.urgency_level, ea.summary, ea.key_points
             FROM emails e
             LEFT JOIN email_analysis ea ON e.id = ea.email_id
             WHERE e.contact_id = ?
             ORDER BY e.{$sortColumn} {$sortOrder}
             LIMIT ? OFFSET ?",
            [$contactId, $limit, $offset]
        );

        return ['emails' => $emails, 'total' => (int)$total];
    }

    private function getEmailsWithAnalysisMongo(
        int $contactId,
        int $limit,
        int $offset,
        string $sortColumn,
        string $sortOrder
    ): array {
        $mongo = MongoDatabase::getInstance();

        $total = $mongo->count('emails', ['contact_id' => $contactId]);

        $sortDir = strtoupper($sortOrder) === 'DESC' ? -1 : 1;

        $pipeline = [
            ['$match' => ['contact_id' => $contactId]],
            ['$lookup' => [
                'from' => 'email_analysis',
                'localField' => 'id',
                'foreignField' => 'email_id',
                'as' => 'analysis'
            ]],
            ['$unwind' => ['path' => '$analysis', 'preserveNullAndEmptyArrays' => true]],
            ['$sort' => [$sortColumn => $sortDir]],
            ['$skip' => $offset],
            ['$limit' => $limit]
        ];

        $results = $mongo->aggregate('emails', $pipeline);

        // Flatten analysis
        $emails = array_map(function($doc) {
            if (isset($doc['analysis'])) {
                $doc['sentiment_label'] = $doc['analysis']['sentiment_label'] ?? null;
                $doc['urgency_level'] = $doc['analysis']['urgency_level'] ?? null;
                $doc['summary'] = $doc['analysis']['summary'] ?? null;
                $doc['key_points'] = $doc['analysis']['key_points'] ?? null;
                unset($doc['analysis']);
            }
            return $doc;
        }, $results);

        return ['emails' => $emails, 'total' => $total];
    }

    /**
     * Get single email with full details
     */
    public function getEmailWithDetails(int $emailId): ?array
    {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->getEmailWithDetails($emailId);
        }

        return $this->db->fetchOne(
            "SELECT e.*, c.name as contact_name, c.email as contact_email,
                    ea.*, ta.summary as thread_summary, ta.key_insights
             FROM emails e
             LEFT JOIN contacts c ON e.contact_id = c.id
             LEFT JOIN email_analysis ea ON e.id = ea.email_id
             LEFT JOIN thread_analysis ta ON e.thread_id = ta.thread_id
             WHERE e.id = ?",
            [$emailId]
        );
    }

    /**
     * Search emails
     */
    public function searchEmails(
        string $query,
        ?int $contactId = null,
        int $limit = 50
    ): array {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->searchEmails($query, [
                'contact_id' => $contactId,
                'limit' => $limit
            ]);
        }

        $whereClause = "(e.subject LIKE ? OR e.body_text LIKE ?)";
        $params = ["%{$query}%", "%{$query}%"];

        if ($contactId) {
            $whereClause .= " AND e.contact_id = ?";
            $params[] = $contactId;
        }

        return $this->db->fetchAll(
            "SELECT e.*, c.name as contact_name, c.email as contact_email
             FROM emails e
             LEFT JOIN contacts c ON e.contact_id = c.id
             WHERE {$whereClause}
             ORDER BY e.date DESC
             LIMIT ?",
            array_merge($params, [$limit])
        );
    }

    // =========================================================================
    // THREADS
    // =========================================================================

    /**
     * Get thread analysis with contact info
     */
    public function getThreadsWithContact(
        ?int $contactId = null,
        int $limit = 50
    ): array {
        if ($this->useMongo) {
            $filter = $contactId ? ['contact_id' => $contactId] : [];
            $repo = new MongoRepository();
            return $repo->getThreadsWithContact($filter, ['limit' => $limit]);
        }

        $whereClause = $contactId ? "ta.contact_id = ?" : "1=1";
        $params = $contactId ? [$contactId, $limit] : [$limit];

        return $this->db->fetchAll(
            "SELECT ta.*, c.name as contact_name, c.email as contact_email
             FROM thread_analysis ta
             LEFT JOIN contacts c ON ta.contact_id = c.id
             WHERE {$whereClause}
             ORDER BY ta.analyzed_at DESC
             LIMIT ?",
            $params
        );
    }

    /**
     * Get list of threads with summary information (paginated)
     *
     * @param int|null $contactId Filter by contact ID
     * @param string $search Search term for subject
     * @param int $limit Results per page
     * @param int $offset Pagination offset
     * @return array ['threads' => [...], 'total' => int]
     */
    public function getThreads(
        ?int $contactId = null,
        string $search = '',
        int $limit = 20,
        int $offset = 0,
        string $sort = 'last_date',
        string $order = 'DESC',
        string $contactName = '',
        string $dateFrom = '',
        string $dateTo = '',
        string $status = ''
    ): array {
        if ($this->useMongo) {
            return $this->getThreadsMongo($contactId, $search, $limit, $offset, $sort, $order, $contactName, $dateFrom, $dateTo, $status);
        }

        return $this->getThreadsSQL($contactId, $search, $limit, $offset);
    }

    private function getThreadsSQL(?int $contactId, string $search, int $limit, int $offset): array
    {
        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';
        $where = 'e.thread_id IS NOT NULL AND e.thread_id != ""';
        $params = [];

        if ($contactId) {
            $where .= ' AND e.contact_id = ?';
            $params[] = $contactId;
        }

        if ($search) {
            $where .= ' AND e.subject LIKE ?';
            $params[] = '%' . $search . '%';
        }

        // Get total thread count
        $total = $this->db->fetchColumn(
            "SELECT COUNT(DISTINCT e.thread_id)
             FROM emails e
             WHERE {$where}",
            $params
        );

        // Get threads with summary info
        // Use subquery for contact_id to find most frequent inbound sender (not MAX)
        $threads = $this->db->fetchAll(
            "SELECT
                e.thread_id,
                MIN(e.id) as first_email_id,
                MAX(e.id) as last_email_id,
                MIN(e.subject) as subject,
                MIN(e.date) as first_date,
                MAX(e.date) as last_date,
                COUNT(*) as email_count,
                SUM(CASE WHEN e.direction = 'inbound' THEN 1 ELSE 0 END) as inbound_count,
                SUM(CASE WHEN e.direction = 'outbound' THEN 1 ELSE 0 END) as outbound_count,
                (SELECT e2.contact_id FROM emails e2
                 WHERE e2.thread_id = e.thread_id
                 AND e2.direction = 'inbound'
                 AND LOWER(e2.from_email) != ?
                 GROUP BY e2.contact_id
                 ORDER BY COUNT(*) DESC LIMIT 1) as contact_id,
                (SELECT c2.name FROM emails e2
                 JOIN contacts c2 ON e2.contact_id = c2.id
                 WHERE e2.thread_id = e.thread_id
                 AND e2.direction = 'inbound'
                 AND LOWER(e2.from_email) != ?
                 GROUP BY e2.contact_id, c2.name
                 ORDER BY COUNT(*) DESC LIMIT 1) as contact_name,
                ta.is_analyzed,
                ta.needs_reanalysis
             FROM emails e
             LEFT JOIN thread_analysis ta ON e.thread_id = ta.thread_id
             WHERE {$where}
             GROUP BY e.thread_id
             ORDER BY MAX(e.date) DESC
             LIMIT ? OFFSET ?",
            array_merge([$userEmail, $userEmail], $params, [$limit, $offset])
        );

        return [
            'threads' => $threads,
            'total' => (int)$total
        ];
    }

    private function getThreadsMongo(
        ?int $contactId,
        string $search,
        int $limit,
        int $offset,
        string $sort = 'last_date',
        string $order = 'DESC',
        string $contactName = '',
        string $dateFrom = '',
        string $dateTo = '',
        string $status = ''
    ): array {
        $mongo = MongoDatabase::getInstance();
        $userEmail = defined('GMAIL_USER_EMAIL') ? strtolower(GMAIL_USER_EMAIL) : '';

        // Build match filter
        $matchFilter = [
            'thread_id' => ['$ne' => null, '$ne' => '']
        ];

        if ($contactId) {
            $matchFilter['contact_id'] = $contactId;
        }

        if ($search) {
            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];
        }

        // Date range filter on email dates
        if ($dateFrom || $dateTo) {
            $dateFilter = [];
            if ($dateFrom) $dateFilter['$gte'] = $dateFrom;
            if ($dateTo) $dateFilter['$lte'] = $dateTo . ' 23:59:59';
            $matchFilter['date'] = $dateFilter;
        }

        // Contact name filter: pre-query contact IDs
        if ($contactName) {
            $matchingContacts = $mongo->findAll('contacts', [
                'name' => ['$regex' => $contactName, '$options' => 'i']
            ], ['projection' => ['id' => 1]]);
            $matchingIds = array_column($matchingContacts, 'id');
            if (!empty($matchingIds)) {
                $matchFilter['contact_id'] = ['$in' => $matchingIds];
            } else {
                return ['threads' => [], 'total' => 0];
            }
        }

        // Count distinct threads for total
        if ($status) {
            // Need full pipeline to filter by analysis status
            $countPipeline = [
                ['$match' => $matchFilter],
                ['$group' => [
                    '_id' => '$thread_id',
                    'email_count' => ['$sum' => 1]
                ]],
                ['$lookup' => [
                    'from' => 'thread_analysis',
                    'localField' => '_id',
                    'foreignField' => 'thread_id',
                    'as' => 'analysis'
                ]],
                ['$addFields' => [
                    'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]]
                ]]
            ];
            if ($status === 'analyzed') {
                $countPipeline[] = ['$match' => ['is_analyzed' => true]];
            } elseif ($status === 'not_analyzed') {
                $countPipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
            }
            $countPipeline[] = ['$count' => 'total'];
            $countResult = $mongo->aggregate('emails', $countPipeline);
            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
        } else {
            $countPipeline = [
                ['$match' => $matchFilter],
                ['$group' => ['_id' => '$thread_id']],
                ['$count' => 'total']
            ];
            $countResult = $mongo->aggregate('emails', $countPipeline);
            $total = !empty($countResult) ? $countResult[0]['total'] : 0;
        }

        // Get threads with aggregated data
        // Collect all contacts per thread to find the most relevant one (not just $max)
        $pipeline = [
            ['$match' => $matchFilter],
            ['$group' => [
                '_id' => '$thread_id',
                'first_email_id' => ['$min' => '$id'],
                'last_email_id' => ['$max' => '$id'],
                'subject' => ['$first' => '$subject'],
                'first_date' => ['$min' => '$date'],
                'last_date' => ['$max' => '$date'],
                'email_count' => ['$sum' => 1],
                'inbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'inbound']], 1, 0]]],
                'outbound_count' => ['$sum' => ['$cond' => [['$eq' => ['$direction', 'outbound']], 1, 0]]],
                'all_contacts' => ['$push' => [
                    'cid' => '$contact_id',
                    'from' => '$from_email',
                    'to' => '$to_email',
                    'dir' => '$direction'
                ]]
            ]],
            ['$lookup' => [
                'from' => 'thread_analysis',
                'localField' => '_id',
                'foreignField' => 'thread_id',
                'as' => 'analysis'
            ]],
            ['$project' => [
                '_id' => 0,
                'thread_id' => '$_id',
                'first_email_id' => 1,
                'last_email_id' => 1,
                'subject' => 1,
                'first_date' => 1,
                'last_date' => 1,
                'email_count' => 1,
                'inbound_count' => 1,
                'outbound_count' => 1,
                'all_contacts' => 1,
                'is_analyzed' => ['$arrayElemAt' => ['$analysis.is_analyzed', 0]],
                'needs_reanalysis' => ['$arrayElemAt' => ['$analysis.needs_reanalysis', 0]]
            ]],
        ];

        // Status filter (after analysis lookup)
        if ($status === 'analyzed') {
            $pipeline[] = ['$match' => ['is_analyzed' => true]];
        } elseif ($status === 'not_analyzed') {
            $pipeline[] = ['$match' => ['$or' => [['is_analyzed' => false], ['is_analyzed' => null]]]];
        }

        // Dynamic sort
        $allowedSorts = ['last_date', 'first_date', 'email_count', 'subject'];
        $sortField = in_array($sort, $allowedSorts) ? $sort : 'last_date';
        $sortDir = strtoupper($order) === 'ASC' ? 1 : -1;

        $pipeline[] = ['$sort' => [$sortField => $sortDir]];
        $pipeline[] = ['$skip' => $offset];
        $pipeline[] = ['$limit' => $limit];

        $threads = $mongo->aggregate('emails', $pipeline);

        // Post-process: find the most frequent external contact per thread
        $contactIds = [];
        foreach ($threads as &$thread) {
            $contactCounts = [];

            foreach ($thread['all_contacts'] ?? [] as $c) {
                $cid = $c['cid'] ?? null;
                $fromEmail = strtolower($c['from'] ?? '');
                $toEmail = strtolower($c['to'] ?? '');
                $dir = $c['dir'] ?? '';

                if (!$cid) continue;

                // For inbound: from_email is external sender (weight 2x)
                if ($dir === 'inbound' && $fromEmail && $fromEmail !== $userEmail) {
                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 2;
                }
                // For outbound: to_email is external recipient (weight 1x)
                elseif ($dir === 'outbound' && $toEmail && $toEmail !== $userEmail) {
                    $contactCounts[$cid] = ($contactCounts[$cid] ?? 0) + 1;
                }
            }

            // Pick the most frequent contact
            if (!empty($contactCounts)) {
                arsort($contactCounts);
                $thread['contact_id'] = (int)array_key_first($contactCounts);
            } else {
                // Fallback: first non-null contact_id
                $thread['contact_id'] = null;
                foreach ($thread['all_contacts'] ?? [] as $c) {
                    if (!empty($c['cid'])) {
                        $thread['contact_id'] = (int)$c['cid'];
                        break;
                    }
                }
            }

            if ($thread['contact_id']) {
                $contactIds[] = $thread['contact_id'];
            }

            unset($thread['all_contacts']);
        }
        unset($thread);

        // Batch lookup contact names
        $contactNames = [];
        if (!empty($contactIds)) {
            $contacts = $mongo->findAll('contacts', [
                'id' => ['$in' => array_values(array_unique($contactIds))]
            ]);
            foreach ($contacts as $c) {
                $contactNames[$c['id']] = $c['name'] ?? '';
            }
        }

        // Assign contact names
        foreach ($threads as &$thread) {
            $thread['contact_name'] = $contactNames[$thread['contact_id'] ?? 0] ?? null;
        }
        unset($thread);

        return [
            'threads' => $threads,
            'total' => $total
        ];
    }

    /**
     * Get full thread details with all emails and analysis
     *
     * @param string $threadId Gmail thread ID
     * @return array ['thread_id', 'emails', 'analysis', 'email_count']
     */
    public function getThreadDetails(string $threadId): array
    {
        if ($this->useMongo) {
            return $this->getThreadDetailsMongo($threadId);
        }

        return $this->getThreadDetailsSQL($threadId);
    }

    private function getThreadDetailsSQL(string $threadId): array
    {
        // Get all emails in thread with contact info
        $emails = $this->db->fetchAll(
            "SELECT e.*, c.name as contact_name, c.email as contact_email
             FROM emails e
             LEFT JOIN contacts c ON e.contact_id = c.id
             WHERE e.thread_id = ?
             ORDER BY e.date ASC",
            [$threadId]
        );

        // Get thread analysis with email analysis
        $analysis = $this->db->fetchOne(
            "SELECT ta.*, ea.summary, ea.sentiment_label, ea.deception_score,
                    ea.raw_analysis, ea.key_points
             FROM thread_analysis ta
             LEFT JOIN email_analysis ea ON ta.last_email_id = ea.email_id
             WHERE ta.thread_id = ?",
            [$threadId]
        );

        return [
            'thread_id' => $threadId,
            'emails' => $emails,
            'email_count' => count($emails),
            'analysis' => $analysis
        ];
    }

    private function getThreadDetailsMongo(string $threadId): array
    {
        $mongo = MongoDatabase::getInstance();

        // Get all emails in thread with contact info using aggregation
        $emailPipeline = [
            ['$match' => ['thread_id' => $threadId]],
            ['$lookup' => [
                'from' => 'contacts',
                'localField' => 'contact_id',
                'foreignField' => 'id',
                'as' => 'contact'
            ]],
            ['$unwind' => ['path' => '$contact', 'preserveNullAndEmptyArrays' => true]],
            ['$sort' => ['date' => 1]],  // ASC
            ['$project' => [
                '_id' => 0,
                'id' => 1,
                'gmail_id' => 1,
                'thread_id' => 1,
                'contact_id' => 1,
                'subject' => 1,
                'from_email' => 1,
                'to_email' => 1,
                'date' => 1,
                'direction' => 1,
                'snippet' => 1,
                'body_text' => 1,
                'body_html' => 1,
                'has_attachments' => 1,
                'label_ids' => 1,
                'contact_name' => '$contact.name',
                'contact_email' => '$contact.email'
            ]]
        ];

        $emails = $mongo->aggregate('emails', $emailPipeline);

        // Get thread analysis with email analysis
        $analysisPipeline = [
            ['$match' => ['thread_id' => $threadId]],
            ['$lookup' => [
                'from' => 'email_analysis',
                'localField' => 'last_email_id',
                'foreignField' => 'email_id',
                'as' => 'email_analysis'
            ]],
            ['$unwind' => ['path' => '$email_analysis', 'preserveNullAndEmptyArrays' => true]],
            ['$project' => [
                '_id' => 0,
                'thread_id' => 1,
                'contact_id' => 1,
                'subject' => 1,
                'email_count' => 1,
                'first_date' => 1,
                'last_date' => 1,
                'last_email_id' => 1,
                'trust_score' => 1,
                'deception_score' => 1,
                'risk_level' => 1,
                'summary' => ['$ifNull' => ['$email_analysis.summary', null]],
                'sentiment_label' => ['$ifNull' => ['$email_analysis.sentiment_label', null]],
                'raw_analysis' => 1,
                'key_insights' => 1,
                'key_points' => ['$ifNull' => ['$email_analysis.key_points', null]],
                'action_items' => 1,
                'warnings' => 1,
                'analyzed_at' => 1,
                'tokens_used' => 1
            ]],
            ['$limit' => 1]
        ];

        $analysisResults = $mongo->aggregate('thread_analysis', $analysisPipeline);
        $analysis = !empty($analysisResults) ? $analysisResults[0] : null;

        return [
            'thread_id' => $threadId,
            'emails' => $emails,
            'email_count' => count($emails),
            'analysis' => $analysis
        ];
    }

    /**
     * Get thread participants with aggregated statistics
     *
     * @param string $threadId Gmail thread ID
     * @param string $userEmail Current user's email for 'self' detection
     * @return array ['participants' => [...], 'subject' => string]
     */
    public function getThreadParticipants(string $threadId, string $userEmail): array
    {

        if ($this->useMongo) {
            return $this->getThreadParticipantsMongo($threadId, $userEmail);
        }

        return $this->getThreadParticipantsSQL($threadId, $userEmail);
    }

    private function getThreadParticipantsSQL(string $threadId, string $userEmail): array
    {
        $userEmail = strtolower($userEmail);

        // Fetch ALL emails in the thread to extract participants from FROM, TO, and CC fields
        $emails = $this->db->fetchAll(
            "SELECT from_email, to_email, cc_email, date, direction, subject
             FROM emails
             WHERE thread_id = ?
             ORDER BY date ASC",
            [$threadId]
        );

        // Collect all unique participants with their stats
        $participantStats = [];
        $threadSubject = '';

        foreach ($emails as $email) {
            // Get subject from first email
            if (empty($threadSubject) && !empty($email['subject'])) {
                $threadSubject = $email['subject'];
            }

            $date = $email['date'] ?? null;
            $direction = $email['direction'] ?? '';

            // Extract FROM participant
            if (!empty($email['from_email'])) {
                $fromEmail = strtolower(trim($email['from_email']));
                if (!isset($participantStats[$fromEmail])) {
                    $participantStats[$fromEmail] = [
                        'email' => $fromEmail,
                        'sent_count' => 0,
                        'received_count' => 0,
                        'total_count' => 0,
                        'first_email' => $date,
                        'last_email' => $date
                    ];
                }
                $participantStats[$fromEmail]['sent_count']++;
                $participantStats[$fromEmail]['total_count']++;
                if ($date) {
                    if (!$participantStats[$fromEmail]['first_email'] || $date < $participantStats[$fromEmail]['first_email']) {
                        $participantStats[$fromEmail]['first_email'] = $date;
                    }
                    if (!$participantStats[$fromEmail]['last_email'] || $date > $participantStats[$fromEmail]['last_email']) {
                        $participantStats[$fromEmail]['last_email'] = $date;
                    }
                }
            }

            // Extract TO participant(s)
            if (!empty($email['to_email'])) {
                $toEmail = strtolower(trim($email['to_email']));
                if (!isset($participantStats[$toEmail])) {
                    $participantStats[$toEmail] = [
                        'email' => $toEmail,
                        'sent_count' => 0,
                        'received_count' => 0,
                        'total_count' => 0,
                        'first_email' => $date,
                        'last_email' => $date
                    ];
                }
                $participantStats[$toEmail]['received_count']++;
                $participantStats[$toEmail]['total_count']++;
                if ($date) {
                    if (!$participantStats[$toEmail]['first_email'] || $date < $participantStats[$toEmail]['first_email']) {
                        $participantStats[$toEmail]['first_email'] = $date;
                    }
                    if (!$participantStats[$toEmail]['last_email'] || $date > $participantStats[$toEmail]['last_email']) {
                        $participantStats[$toEmail]['last_email'] = $date;
                    }
                }
            }

            // Extract CC participant(s)
            if (!empty($email['cc_email'])) {
                // CC might be comma-separated, split it
                $ccEmails = array_map('trim', explode(',', $email['cc_email']));
                foreach ($ccEmails as $ccEmail) {
                    $ccEmail = strtolower($ccEmail);
                    if (empty($ccEmail)) continue;

                    if (!isset($participantStats[$ccEmail])) {
                        $participantStats[$ccEmail] = [
                            'email' => $ccEmail,
                            'sent_count' => 0,
                            'received_count' => 0,
                            'total_count' => 0,
                            'first_email' => $date,
                            'last_email' => $date
                        ];
                    }
                    $participantStats[$ccEmail]['received_count']++;
                    $participantStats[$ccEmail]['total_count']++;
                    if ($date) {
                        if (!$participantStats[$ccEmail]['first_email'] || $date < $participantStats[$ccEmail]['first_email']) {
                            $participantStats[$ccEmail]['first_email'] = $date;
                        }
                        if (!$participantStats[$ccEmail]['last_email'] || $date > $participantStats[$ccEmail]['last_email']) {
                            $participantStats[$ccEmail]['last_email'] = $date;
                        }
                    }
                }
            }
        }

        // Convert to array and sort by total_count
        $participants = array_values($participantStats);
        usort($participants, function($a, $b) {
            return $b['total_count'] - $a['total_count'];
        });

        // Enrich with contact data and determine participant type
        foreach ($participants as &$p) {
            // Determine participant type
            $p['participant_type'] = (strtolower($p['email']) === $userEmail) ? 'self' : 'external';

            // Calculate inbound/outbound based on direction and participant type
            if ($p['participant_type'] === 'self') {
                $p['inbound_count'] = $p['received_count'];
                $p['outbound_count'] = $p['sent_count'];
            } else {
                $p['inbound_count'] = $p['sent_count'];  // External sent TO us = inbound
                $p['outbound_count'] = $p['received_count'];  // We sent TO external = outbound (for us)
            }

            $p['email_count'] = $p['total_count'];

            // Get name guess from email
            $emailParts = explode('@', $p['email']);
            $namePart = $emailParts[0] ?? '';
            $nameGuess = ucfirst(explode('.', $namePart)[0]);

            // Fetch contact data from contacts table
            $contact = $this->db->fetchOne(
                "SELECT id, name, email, company, type FROM contacts WHERE email = ?",
                [$p['email']]
            );

            // Fetch enrichment data from contact_enrichment table
            $enrichment = null;
            if ($contact && !empty($contact['id'])) {
                $enrichment = $this->db->fetchOne(
                    "SELECT job_title, phone, mobile_phone, linkedin_url
                     FROM contact_enrichment
                     WHERE contact_id = ?",
                    [$contact['id']]
                );
            }

            if ($p['participant_type'] === 'external') {
                if ($contact) {
                    $p['contact_id'] = $contact['id'];
                    $p['name'] = $contact['name'] ?? $nameGuess;
                    $p['company'] = $contact['company'] ?? '';
                    $p['contact_type'] = $contact['type'] ?? null;

                    // Add enriched data
                    if ($enrichment) {
                        $p['job_title'] = $enrichment['job_title'] ?? null;
                        $p['phone'] = $enrichment['phone'] ?? $enrichment['mobile_phone'] ?? null;
                        $p['linkedin_url'] = $enrichment['linkedin_url'] ?? null;
                    }
                } else {
                    $p['contact_id'] = null;
                    $p['name'] = $nameGuess;
                    $p['company'] = $emailParts[1] ?? '';
                    $p['contact_type'] = null;
                }
            } else {
                $p['name'] = 'You';
                $p['company'] = '';
                $p['contact_id'] = $contact ? $contact['id'] : null;
                $p['contact_type'] = null;
            }

            // Remove temporary fields
            unset($p['sent_count'], $p['received_count'], $p['total_count']);
        }

        return [
            'participants' => $participants,
            'subject' => $threadSubject
        ];
    }

    private function getThreadParticipantsMongo(string $threadId, string $userEmail): array
    {

        $mongo = MongoDatabase::getInstance();
        $userEmail = strtolower($userEmail);

        // Fetch ALL emails in the thread to extract participants from FROM, TO, and CC fields
        $emails = $mongo->findAll('emails', ['thread_id' => $threadId]);

        // Collect all unique participants with their stats
        $participantStats = [];
        $threadSubject = '';

        foreach ($emails as $email) {
            // Get subject from first email
            if (empty($threadSubject) && !empty($email['subject'])) {
                $threadSubject = $email['subject'];
            }

            $date = $email['date'] ?? null;
            $direction = $email['direction'] ?? '';

            // Extract FROM participant
            if (!empty($email['from_email'])) {
                $fromEmail = strtolower(trim($email['from_email']));
                if (!isset($participantStats[$fromEmail])) {
                    $participantStats[$fromEmail] = [
                        'email' => $fromEmail,
                        'sent_count' => 0,
                        'received_count' => 0,
                        'total_count' => 0,
                        'first_email' => $date,
                        'last_email' => $date
                    ];
                }
                $participantStats[$fromEmail]['sent_count']++;
                $participantStats[$fromEmail]['total_count']++;
                if ($date) {
                    if (!$participantStats[$fromEmail]['first_email'] || $date < $participantStats[$fromEmail]['first_email']) {
                        $participantStats[$fromEmail]['first_email'] = $date;
                    }
                    if (!$participantStats[$fromEmail]['last_email'] || $date > $participantStats[$fromEmail]['last_email']) {
                        $participantStats[$fromEmail]['last_email'] = $date;
                    }
                }
            }

            // Extract TO participant(s)
            if (!empty($email['to_email'])) {
                $toEmail = strtolower(trim($email['to_email']));
                if (!isset($participantStats[$toEmail])) {
                    $participantStats[$toEmail] = [
                        'email' => $toEmail,
                        'sent_count' => 0,
                        'received_count' => 0,
                        'total_count' => 0,
                        'first_email' => $date,
                        'last_email' => $date
                    ];
                }
                $participantStats[$toEmail]['received_count']++;
                $participantStats[$toEmail]['total_count']++;
                if ($date) {
                    if (!$participantStats[$toEmail]['first_email'] || $date < $participantStats[$toEmail]['first_email']) {
                        $participantStats[$toEmail]['first_email'] = $date;
                    }
                    if (!$participantStats[$toEmail]['last_email'] || $date > $participantStats[$toEmail]['last_email']) {
                        $participantStats[$toEmail]['last_email'] = $date;
                    }
                }
            }

            // Extract CC participant(s)
            if (!empty($email['cc_email'])) {
                // CC might be comma-separated, split it
                $ccEmails = array_map('trim', explode(',', $email['cc_email']));
                foreach ($ccEmails as $ccEmail) {
                    $ccEmail = strtolower($ccEmail);
                    if (empty($ccEmail)) continue;

                    if (!isset($participantStats[$ccEmail])) {
                        $participantStats[$ccEmail] = [
                            'email' => $ccEmail,
                            'sent_count' => 0,
                            'received_count' => 0,
                            'total_count' => 0,
                            'first_email' => $date,
                            'last_email' => $date
                        ];
                    }
                    $participantStats[$ccEmail]['received_count']++;
                    $participantStats[$ccEmail]['total_count']++;
                    if ($date) {
                        if (!$participantStats[$ccEmail]['first_email'] || $date < $participantStats[$ccEmail]['first_email']) {
                            $participantStats[$ccEmail]['first_email'] = $date;
                        }
                        if (!$participantStats[$ccEmail]['last_email'] || $date > $participantStats[$ccEmail]['last_email']) {
                            $participantStats[$ccEmail]['last_email'] = $date;
                        }
                    }
                }
            }
        }

        // Convert to array and sort by total_count
        $participants = array_values($participantStats);
        usort($participants, function($a, $b) {
            return $b['total_count'] - $a['total_count'];
        });

        // Enrich with contact data and determine participant type
        foreach ($participants as &$p) {
            // Determine participant type
            $p['participant_type'] = (strtolower($p['email']) === $userEmail) ? 'self' : 'external';

            // Calculate inbound/outbound based on direction and participant type
            if ($p['participant_type'] === 'self') {
                $p['inbound_count'] = $p['received_count'];
                $p['outbound_count'] = $p['sent_count'];
            } else {
                $p['inbound_count'] = $p['sent_count'];  // External sent TO us = inbound
                $p['outbound_count'] = $p['received_count'];  // We sent TO external = outbound (for us)
            }

            $p['email_count'] = $p['total_count'];

            // Get name guess from email
            $emailParts = explode('@', $p['email']);
            $namePart = $emailParts[0] ?? '';
            $nameGuess = ucfirst(explode('.', $namePart)[0]);

            // Fetch contact data from contacts collection
            $contact = $mongo->findOne('contacts', ['email' => $p['email']]);

            // Fetch enrichment data from contact_enrichment collection
            $enrichment = null;
            if ($contact && !empty($contact['id'])) {
                $enrichment = $mongo->findOne('contact_enrichment', ['contact_id' => $contact['id']]);
            }

            if ($p['participant_type'] === 'external') {
                if ($contact) {
                    $p['contact_id'] = $contact['id'] ?? null;
                    $p['name'] = $contact['name'] ?? $nameGuess;
                    $p['company'] = $contact['company'] ?? '';
                    $p['contact_type'] = $contact['type'] ?? null;

                    // Add enriched data
                    if ($enrichment) {
                        $p['job_title'] = $enrichment['job_title'] ?? null;
                        $p['phone'] = $enrichment['phone'] ?? $enrichment['mobile_phone'] ?? null;
                        $p['linkedin_url'] = $enrichment['linkedin_url'] ?? null;
                    }
                } else {
                    $p['contact_id'] = null;
                    $p['name'] = $nameGuess;
                    $p['company'] = $emailParts[1] ?? '';
                    $p['contact_type'] = null;
                }
            } else {
                $p['name'] = 'You';
                $p['company'] = '';
                $p['contact_id'] = $contact['id'] ?? null;
                $p['contact_type'] = null;
            }

            // Remove temporary fields
            unset($p['sent_count'], $p['received_count'], $p['total_count']);
        }

        return [
            'participants' => $participants,
            'subject' => $threadSubject
        ];
    }

    // =========================================================================
    // LOGS
    // =========================================================================

    /**
     * Get API logs with optional filtering
     *
     * @param string|null $threadId Filter by thread ID
     * @param string|null $level Filter by log level (info, warning, error)
     * @param int $limit Maximum number of logs to return
     * @return array ['logs' => [...], 'count' => int]
     */
    public function getLogs(?string $threadId = null, ?string $level = null, int $limit = 50): array
    {
        if ($this->useMongo) {
            return $this->getLogsMongo($threadId, $level, $limit);
        }

        return $this->getLogsSQL($threadId, $level, $limit);
    }

    private function getLogsSQL(?string $threadId, ?string $level, int $limit): array
    {
        $where = '1=1';
        $params = [];

        if ($threadId) {
            $where .= ' AND thread_id = ?';
            $params[] = $threadId;
        }
        if ($level) {
            $where .= ' AND log_level = ?';
            $params[] = $level;
        }

        $logs = $this->db->fetchAll(
            "SELECT id, log_level, source, message, context, thread_id, created_at
             FROM api_logs
             WHERE {$where}
             ORDER BY created_at DESC
             LIMIT ?",
            array_merge($params, [$limit])
        );

        // Parse context JSON (MySQL stores as string)
        foreach ($logs as &$log) {
            if (isset($log['context']) && is_string($log['context'])) {
                $log['context'] = json_decode($log['context'], true);
            }
        }

        return [
            'logs' => $logs,
            'count' => count($logs)
        ];
    }

    private function getLogsMongo(?string $threadId, ?string $level, int $limit): array
    {
        $mongo = MongoDatabase::getInstance();

        $filter = [];

        if ($threadId) {
            $filter['thread_id'] = $threadId;
        }
        if ($level) {
            $filter['log_level'] = $level;
        }

        $logs = $mongo->findAll('api_logs', $filter, [
            'sort' => ['created_at' => -1],
            'limit' => $limit
        ]);

        // Context should already be an array in MongoDB, but handle string case for safety
        foreach ($logs as &$log) {
            if (isset($log['context']) && is_string($log['context'])) {
                $log['context'] = json_decode($log['context'], true);
            }
        }

        return [
            'logs' => $logs,
            'count' => count($logs)
        ];
    }

    // =========================================================================
    // ALERTS
    // =========================================================================

    /**
     * Get alerts with contact info
     */
    public function getAlertsWithContact(
        ?string $status = null,
        int $limit = 50
    ): array {
        if ($this->useMongo) {
            $filter = $status ? ['status' => $status] : [];
            $repo = new MongoRepository();
            return $repo->getAlertsWithContact($filter, ['limit' => $limit]);
        }

        $whereClause = $status ? "a.status = ?" : "1=1";
        $params = $status ? [$status, $limit] : [$limit];

        return $this->db->fetchAll(
            "SELECT a.*, c.name as contact_name, c.email as contact_email
             FROM alerts a
             LEFT JOIN contacts c ON a.contact_id = c.id
             WHERE {$whereClause}
             ORDER BY a.created_at DESC
             LIMIT ?",
            $params
        );
    }

    // =========================================================================
    // BUSINESS INTELLIGENCE
    // =========================================================================

    /**
     * Get churn risk indicators with contacts
     */
    public function getChurnRisk(int $limit = 20): array
    {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->getChurnRiskWithContacts(['limit' => $limit]);
        }

        return $this->db->fetchAll(
            "SELECT cri.*, c.name, c.email, c.company
             FROM churn_risk_indicators cri
             JOIN contacts c ON cri.contact_id = c.id
             ORDER BY cri.risk_score DESC
             LIMIT ?",
            [$limit]
        );
    }

    /**
     * Get business opportunities with contacts
     */
    public function getBusinessOpportunities(int $limit = 20): array
    {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->getBusinessOpportunities([], ['limit' => $limit]);
        }

        return $this->db->fetchAll(
            "SELECT bo.*, c.name, c.email, c.company
             FROM business_opportunities bo
             JOIN contacts c ON bo.contact_id = c.id
             ORDER BY bo.created_at DESC
             LIMIT ?",
            [$limit]
        );
    }

    /**
     * Get leverage/bargaining chips
     */
    public function getLeverage(int $contactId, int $limit = 10): array
    {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->getLeverageWithContacts(
                ['contact_id' => $contactId],
                ['limit' => $limit]
            );
        }

        return $this->db->fetchAll(
            "SELECT lb.*, c.name as contact_name
             FROM leverage_bargaining lb
             LEFT JOIN contacts c ON lb.contact_id = c.id
             WHERE lb.contact_id = ?
             ORDER BY lb.strength DESC
             LIMIT ?",
            [$contactId, $limit]
        );
    }

    // =========================================================================
    // STATISTICS
    // =========================================================================

    /**
     * Get contact email statistics
     */
    public function getContactStats(int $contactId): array
    {
        if ($this->useMongo) {
            $repo = new MongoRepository();
            return $repo->getContactEmailStats($contactId);
        }

        return $this->db->fetchOne(
            "SELECT
                COUNT(*) as email_count,
                SUM(CASE WHEN direction = 'sent' THEN 1 ELSE 0 END) as sent_count,
                SUM(CASE WHEN direction = 'received' THEN 1 ELSE 0 END) as received_count,
                MIN(date) as first_email,
                MAX(date) as last_email
             FROM emails
             WHERE contact_id = ?",
            [$contactId]
        ) ?? ['email_count' => 0, 'sent_count' => 0, 'received_count' => 0];
    }

    // =========================================================================
    // RAW ACCESS (for custom queries)
    // =========================================================================

    /**
     * Get the underlying Database instance (for SQL)
     */
    public function getDatabase(): Database
    {
        return $this->db;
    }

    /**
     * Get the MongoRepository instance (for custom MongoDB queries)
     */
    public function getMongoRepository(): ?MongoRepository
    {
        return $this->mongoRepo;
    }

    /**
     * Delete a contact and all associated data (emails, analysis, profiles, memories, AI responses)
     */
    public function deleteContact(int $contactId): array
    {
        $mongo = MongoDatabase::getInstance();
        $result = [];

        // Get thread_ids for this contact's emails before deleting them
        $emailDocs = $mongo->findAll('emails', ['contact_id' => $contactId], ['projection' => ['thread_id' => 1]]);
        $threadIds = [];
        foreach ($emailDocs as $doc) {
            if (!empty($doc['thread_id'])) {
                $threadIds[$doc['thread_id']] = true;
            }
        }
        $threadIds = array_keys($threadIds);

        // Delete emails
        $result['emails'] = $mongo->delete('emails', ['contact_id' => $contactId]);

        // For each thread, check if any emails remain; if not, delete thread_analysis
        $result['thread_analysis'] = 0;
        foreach ($threadIds as $tid) {
            $remaining = $mongo->count('emails', ['thread_id' => $tid]);
            if ($remaining === 0) {
                $result['thread_analysis'] += $mongo->delete('thread_analysis', ['thread_id' => $tid]);
            }
        }

        // Delete contact profile
        $result['contact_profiles'] = $mongo->delete('contact_profiles', ['contact_id' => $contactId]);

        // Delete AI contact memories
        $result['ai_contact_memory'] = $mongo->delete('ai_contact_memory', ['contact_id' => $contactId]);

        // Delete AI query responses for this contact
        $result['ai_query_responses'] = $mongo->delete('ai_query_responses', ['contact_id' => $contactId]);

        // Delete the contact record
        $result['contacts'] = $mongo->delete('contacts', ['id' => $contactId]);

        return $result;
    }

    /**
     * Delete a thread and all associated data (emails, analysis, AI responses)
     */
    public function deleteThread(string $threadId): array
    {
        $mongo = MongoDatabase::getInstance();
        $result = [];

        $result['emails'] = $mongo->delete('emails', ['thread_id' => $threadId]);
        $result['thread_analysis'] = $mongo->delete('thread_analysis', ['thread_id' => $threadId]);
        $result['ai_query_responses'] = $mongo->delete('ai_query_responses', ['thread_ids' => $threadId]);

        return $result;
    }

    /**
     * Delete all threads involving a specific email address or domain
     */
    public function deleteThreadsByEmail(string $email = '', string $domain = ''): array
    {
        $mongo = MongoDatabase::getInstance();

        // Build filter to find matching emails
        $filter = [];
        if ($email) {
            $emailLower = strtolower($email);
            $filter['$or'] = [
                ['from_email' => $emailLower],
                ['to_email' => $emailLower]
            ];
        } elseif ($domain) {
            $domainPattern = '@' . preg_quote($domain, '/') . '$';
            $filter['$or'] = [
                ['from_email' => ['$regex' => $domainPattern, '$options' => 'i']],
                ['to_email' => ['$regex' => $domainPattern, '$options' => 'i']]
            ];
        }

        // Aggregate unique thread_ids
        $pipeline = [
            ['$match' => $filter],
            ['$group' => ['_id' => '$thread_id']]
        ];
        $threadResults = $mongo->aggregate('emails', $pipeline);
        $threadIds = array_column($threadResults, '_id');

        $result = ['thread_count' => count($threadIds), 'emails' => 0, 'thread_analysis' => 0, 'ai_query_responses' => 0];

        foreach ($threadIds as $tid) {
            $r = $this->deleteThread($tid);
            $result['emails'] += $r['emails'] ?? 0;
            $result['thread_analysis'] += $r['thread_analysis'] ?? 0;
            $result['ai_query_responses'] += $r['ai_query_responses'] ?? 0;
        }

        return $result;
    }
}

-            $matchFilter['subject'] = ['$regex' => $search, '$options' => 'i'];

+                ['subject' => ['$regex' => $search, '$options' => 'i']],

+                ['from_email' => ['$regex' => $search, '$options' => 'i']],

+                ['to_email' => ['$regex' => $search, '$options' => 'i']]


Now let me verify with a quick API test:


Bash

curl -s "https://mssql.danmarcrm.com/dev1/extension2v1/public/api.php?action=threads&search=delta-engineering.be&limit=5" 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print('Total:', d['pagination']['total']); [print(t['thread_id'], '-', t['subject'][:60], '-', t.get('contact_name','?')) for t in d['threads']]"

Test thread search by domain name


Total: 296
19c819352ea80fe1 -  - None
18668e45237c2001 - Re: New marketing lists - Danny De Bruyn
17fe946e99b1cda3 - Re: Mautic - Sara Cuypers
1849a38f4587a787 - RE: Verify domain please - Sara Cuypers
18499d6c0935ef6b - RE: Sendgrid verification - Alwin De Moor


296 threads now found for "delta-engineering.be" — searching across subject, from_email, and to_email. Previously it was only matching the 1 thread that had the domain in the subject line.

Let me also verify the PHP syntax is clean:


php -l /var/www/mssql.danmarcrm.com/dev1/extension2v1/src/QueryAdapter.php 2>&1

PHP syntax check


No syntax errors detected in /var/www/mssql.danmarcrm.com/dev1/extension2v1/src/QueryAdapter.php


Both fixes are in:

1. **Thread search** — now searches `subject`, `from_email`, and `to_email` (was subject-only)
2. **Thread pagination** — page 2/3/etc buttons now work (was broken due to anonymous function having empty `.name`)

Refresh and try searching "delta-engineering.be" again — you should see all 296 threads.


<ide_opened_file>The user opened the file /var/www/mssql.danmarcrm.com/dev1/extension2v1/src/QueryAdapter.php in the IDE. This may or may not be related to the current task.</ide_opened_file>

If I try in
https://mssql.danmarcrm.com/dev1/extension2v1/public/thread_view.php?thread_id=17f1bcc6d4d2e6ac
to add all threads and emails from delta-engineering.be domain, there are some shown but not all for sure.

In threads seems returns many more when searching for delta-engineering.be

Should return all of them and have a easy method to add all for context


The user is talking about the context builder in thread_view.php. When they search for threads from delta-engineering.be to add as context for AI analysis, not all threads are showing up. They want:

1. The context builder search to return ALL matching threads (currently probably limited)
2. An easy "Add All" button to add all results at once

Let me look at how the context builder search works in thread_view.php.


Let me look at how the context builder search works in thread_view.php.


876:        #context-search {
886:        #context-search:focus {
891:        .context-search-results {
1320:                        <input type="text" id="context-search" placeholder="Search contacts or threads..." oninput="debounceContextSearch()">
1321:

1621:                document.getElementById('context-search').focus();
1630:                var q = document.getElementById('context-search').value.trim();
1634:                    document.getElementById('context-search-results').innerHTML = '';
1640:            var resultsEl = document.getElementById('context-search-results');
1697:            var resultsEl = document.getElementById('context-search-results');
1752:            var q = document.getElementById('context-search').value.trim();
1767:            var q = document.getElementById('context-search').value.trim();


1615→        var contactMemories = [];
  1616→
  1617→        function toggleContextBuilder() {
  1618→            var el = document.getElementById('context-builder');
  1619→            if (el.style.display === 'none') {
  1620→                el.style.display = 'block';
  1621→                document.getElementById('context-search').focus();
  1622→            } else {
  1623→                el.style.display = 'none';
  1624→            }
  1625→        }
  1626→
  1627→        function debounceContextSearch() {
  1628→            clearTimeout(contextSearchTimeout);
  1629→            contextSearchTimeout = setTimeout(function() {
  1630→                var q = document.getElementById('context-search').value.trim();
  1631→                if (q.length >= 2) {
  1632→                    searchContext(q);
  1633→                } else {
  1634→                    document.getElementById('context-search-results').innerHTML = '';
  1635→                }
  1636→            }, 300);
  1637→        }
  1638→
  1639→        function searchContext(query) {
  1640→            var resultsEl = document.getElementById('context-search-results');
  1641→            resultsEl.innerHTML = '
Searching...
';
  1642→
  1643→            // Search contacts and threads in parallel
  1644→            Promise.all([
  1645→                fetch('api.php?action=contacts&search=' + encodeURIComponent(query) + '&limit=5').then(function(r) { return r.json(); }),
  1646→                fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=5').then(function(r) { return r.json(); })
  1647→            ]).then(function(results) {
  1648→                var contacts = results[0].contacts || [];
  1649→                var threads = results[1].threads || [];
  1650→                var html = '';
  1651→
  1652→                if (contacts.length > 0) {
  1653→                    html += '
CONTACTS
';
  1654→                    contacts.forEach(function(c) {
  1655→                        html += '
';
  1656→                        html += '
';
  1657→                        html += '' + escapeHtml(c.name || c.email) + '';
  1658→                        html += '
' + escapeHtml(c.email || '') + ' | ' + (c.email_count || 0) + ' emails
';
  1659→                        html += '
';
  1660→                        html += '<button class="context-add-btn" onclick="loadContactThreads(' + (c.id || c.contact_id) + ', \'' + escapeHtml(c.name || '') + '\')">View Threads</button>';
  1661→                        html += '
';
  1662→                    });
  1663→                }
  1664→
  1665→                if (threads.length > 0) {
  1666→                    html += '
THREADS
';
  1667→                    threads.forEach(function(t) {
  1668→                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
  1669→                        var isCurrent = t.thread_id === currentThreadId;
  1670→                        html += '
';
  1671→                        html += '
';
  1672→                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
  1673→                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
  1674→                        html += '
';
  1675→                        if (isCurrent) {
  1676→                            html += 'Current';
  1677→                        } else if (already) {
  1678→                            html += 'Added';
  1679→                        } else {
  1680→                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
  1681→                        }
  1682→                        html += '
';
  1683→                    });
  1684→                }
  1685→
  1686→                if (!html) {
  1687→                    html = '
No results found
';
  1688→                }
  1689→
  1690→                resultsEl.innerHTML = html;
  1691→            }).catch(function(err) {
  1692→                resultsEl.innerHTML = '
Search failed
';
  1693→            });
  1694→        }
  1695→
  1696→        function loadContactThreads(contactId, contactName) {
  1697→            var resultsEl = document.getElementById('context-search-results');
  1698→            resultsEl.innerHTML = '
Loading threads for ' + escapeHtml(contactName) + '...
';
  1699→
  1700→            fetch('api.php?action=threads&contact=' + contactId + '&limit=20')
  1701→                .then(function(r) { return r.json(); })
  1702→                .then(function(data) {
  1703→                    var threads = data.threads || [];
  1704→                    var html = '
Threads from ' + escapeHtml(contactName) + '
';
  1705→
  1706→                    if (threads.length > 1) {
  1707→                        html += '
<button class="context-add-btn" onclick="addAllContactThreads()" style="width:100%;text-align:center;">Add All ' + threads.length + ' Threads</button>
';
  1708→                    }
  1709→
  1710→                    // Store for "Add All"
  1711→                    window._lastContactThreads = threads;
  1712→
  1713→                    threads.forEach(function(t) {
  1714→                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });

var contactMemories = [];

        function toggleContextBuilder() {
            var el = document.getElementById('context-builder');
            if (el.style.display === 'none') {
                el.style.display = 'block';
                document.getElementById('context-search').focus();
            } else {
                el.style.display = 'none';
            }
        }

        function debounceContextSearch() {
            clearTimeout(contextSearchTimeout);
            contextSearchTimeout = setTimeout(function() {
                var q = document.getElementById('context-search').value.trim();
                if (q.length >= 2) {
                    searchContext(q);
                } else {
                    document.getElementById('context-search-results').innerHTML = '';
                }
            }, 300);
        }

        function searchContext(query) {
            var resultsEl = document.getElementById('context-search-results');
            resultsEl.innerHTML = '
Searching...
';

            // Search contacts and threads in parallel
            Promise.all([
                fetch('api.php?action=contacts&search=' + encodeURIComponent(query) + '&limit=5').then(function(r) { return r.json(); }),
                fetch('api.php?action=threads&search=' + encodeURIComponent(query) + '&limit=5').then(function(r) { return r.json(); })
            ]).then(function(results) {
                var contacts = results[0].contacts || [];
                var threads = results[1].threads || [];
                var html = '';

                if (contacts.length > 0) {
                    html += '
CONTACTS
';
                    contacts.forEach(function(c) {
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(c.name || c.email) + '';
                        html += '
' + escapeHtml(c.email || '') + ' | ' + (c.email_count || 0) + ' emails
';
                        html += '
';
                        html += '<button class="context-add-btn" onclick="loadContactThreads(' + (c.id || c.contact_id) + ', \'' + escapeHtml(c.name || '') + '\')">View Threads</button>';
                        html += '
';
                    });
                }

                if (threads.length > 0) {
                    html += '
THREADS
';
                    threads.forEach(function(t) {
                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });
                        var isCurrent = t.thread_id === currentThreadId;
                        html += '
';
                        html += '
';
                        html += '' + escapeHtml(t.subject || '(No Subject)') + '';
                        html += '
' + (t.email_count || 0) + ' emails | ' + escapeHtml(t.contact_name || '') + '
';
                        html += '
';
                        if (isCurrent) {
                            html += 'Current';
                        } else if (already) {
                            html += 'Added';
                        } else {
                            html += '<button class="context-add-btn" onclick="addThreadToContext(\'' + t.thread_id + '\', \'' + escapeHtml(t.subject || '').replace(/'/g, "\\'") + '\', ' + (t.email_count || 0) + ')">+ Add</button>';
                        }
                        html += '
';
                    });
                }

                if (!html) {
                    html = '
No results found
';
                }

                resultsEl.innerHTML = html;
            }).catch(function(err) {
                resultsEl.innerHTML = '
Search failed
';
            });
        }

        function loadContactThreads(contactId, contactName) {
            var resultsEl = document.getElementById('context-search-results');
            resultsEl.innerHTML = '
Loading threads for ' + escapeHtml(contactName) + '...
';

            fetch('api.php?action=threads&contact=' + contactId + '&limit=20')
                .then(function(r) { return r.json(); })
                .then(function(data) {
                    var threads = data.threads || [];
                    var html = '
Threads from ' + escapeHtml(contactName) + '
';

                    if (threads.length > 1) {
                        html += '
<button class="context-add-btn" onclick="addAllContactThreads()" style="width:100%;text-align:center;">Add All ' + threads.length + ' Threads</button>
';
                    }

                    // Store for "Add All"
                    window._lastContactThreads = threads;

                    threads.forEach(function(t) {
                        var already = contextThreads.some(function(ct) { return ct.threadId === t.thread_id; });