From 17008779182a563df69813489f4debbc5eaad37e Mon Sep 17 00:00:00 2001 From: Sagent Date: Thu, 11 Jun 2026 12:18:20 +0000 Subject: [PATCH 1/7] chore: add unit tests for pure functions + CI test job - Unit tests for normalize_email_address, payload_matches_participant, format_search_result (9 test cases across 3 test classes) - New 'test' job in CI workflow (runs before build) - pytest.ini for pythonpath config --- .gitea/workflows/docker-build.yaml | 19 +++++- pytest.ini | 3 + tests/test_server.py | 106 +++++++++++++++++++++++++++++ 3 files changed, 125 insertions(+), 3 deletions(-) create mode 100644 pytest.ini create mode 100644 tests/test_server.py diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 9bbe8a4..8c597d6 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -9,11 +9,24 @@ on: - cron: '0 0 * * *' jobs: - build: + test: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + pip install -r requirements.txt pytest + - name: Run unit tests + run: pytest tests/ -v + + build: + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - name: Set up Docker Buildx uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..80432c2 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = src diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..1a129b2 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,106 @@ +"""Unit tests for mcp-maildir server pure functions.""" + +from server import ( + normalize_email_address, + payload_matches_participant, + format_search_result, +) + + +class TestNormalizeEmailAddress: + def test_empty_value(self): + assert normalize_email_address("") == "" + assert normalize_email_address(None) == "" + + def test_simple_email(self): + assert normalize_email_address("user@example.com") == "user@example.com" + + def test_display_name_with_email(self): + assert normalize_email_address("John Doe ") == "john@example.com" + + def test_whitespace_and_case(self): + assert normalize_email_address(" USER@Example.COM ") == "user@example.com" + + def test_invalid_email_fallback(self): + result = normalize_email_address("not-an-email") + # Returns stripped lowercase version of the input + assert result == "not-an-email" + + +class TestPayloadMatchesParticipant: + def test_sender_match_normalized(self): + payload = {"sender": "alice@example.com"} + assert payload_matches_participant(payload, "alice@example.com") is True + + def test_receiver_match_normalized(self): + payload = {"receiver": "bob@example.com"} + assert payload_matches_participant(payload, "bob@example.com") is True + + def test_sender_raw_match(self): + payload = {"sender_raw": "alice@other.com"} + assert payload_matches_participant(payload, "alice@other.com") is True + + def test_no_match(self): + payload = {"sender": "alice@example.com", "receiver": "bob@example.com"} + assert payload_matches_participant(payload, "carol@example.com") is False + + def test_display_name_in_sender(self): + payload = {"sender": "Alice "} + assert payload_matches_participant(payload, "alice@example.com") is True + + def test_display_name_in_receiver(self): + payload = {"receiver": "Bob Smith "} + assert payload_matches_participant(payload, "bob@example.com") is True + + def test_empty_payload(self): + assert payload_matches_participant({}, "someone@example.com") is False + + def test_case_insensitive(self): + payload = {"sender": "ALICE@EXAMPLE.COM"} + assert payload_matches_participant(payload, "alice@example.com") is True + + +class TestFormatSearchResult: + def test_basic_formatting(self): + class MockPoint: + payload = { + "message_id": "", + "date": "2026-01-15T10:00:00", + "sender": "alice@example.com", + "receiver": "bob@example.com", + "subject": "Hello", + "attachments": ["file.pdf"], + } + score = 0.95 + + result = format_search_result(MockPoint()) + assert result["message_id"] == "" + assert result["date"] == "2026-01-15T10:00:00" + assert result["sender"] == "alice@example.com" + assert result["receiver"] == "bob@example.com" + assert result["subject"] == "Hello" + assert result["attachments"] == ["file.pdf"] + assert result["score"] == 0.95 + + def test_empty_payload(self): + class MockPoint: + payload = None + score = None + + result = format_search_result(MockPoint()) + assert result["message_id"] is None + assert result["score"] is None + assert result["attachments"] == [] + + def test_missing_fields(self): + class MockPoint: + payload = {"message_id": "<123>"} + score = 0.5 + + result = format_search_result(MockPoint()) + assert result["message_id"] == "<123>" + assert result["date"] is None + assert result["sender"] is None + assert result["receiver"] is None + assert result["subject"] is None + assert result["attachments"] == [] -- 2.52.0 From 78ce84f4ff7318278ea9b9f40181d7b07823de00 Mon Sep 17 00:00:00 2001 From: Sagent Date: Thu, 11 Jun 2026 12:24:46 +0000 Subject: [PATCH 2/7] fix: set QDRANT_URL/COLLECTION_NAME env vars for test import --- tests/test_server.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/test_server.py b/tests/test_server.py index 1a129b2..a4c71d8 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1,5 +1,9 @@ """Unit tests for mcp-maildir server pure functions.""" +import os +os.environ["QDRANT_URL"] = "http://localhost:6333" +os.environ["COLLECTION_NAME"] = "test" + from server import ( normalize_email_address, payload_matches_participant, -- 2.52.0 From 07e7de2811c0d2ff263890fd4993754b8e64be65 Mon Sep 17 00:00:00 2001 From: Sagent Date: Thu, 11 Jun 2026 12:30:59 +0000 Subject: [PATCH 3/7] chore: add integration tests with real Qdrant instance - Integration tests for search_emails and read_email against live Qdrant - Indexes 3 test emails, tests search by content/participant/date - CI: new 'integration-test' job with qdrant service, runs before build - Unit test job ignores integration test file --- .gitea/workflows/docker-build.yaml | 29 ++++- tests/test_integration.py | 175 +++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+), 5 deletions(-) create mode 100644 tests/test_integration.py diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 8c597d6..63aa8d8 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -20,11 +20,33 @@ jobs: run: | pip install -r requirements.txt pytest - name: Run unit tests - run: pytest tests/ -v + run: pytest tests/ -v --ignore=tests/test_integration.py + + integration-test: + runs-on: ubuntu-latest + services: + qdrant: + image: docker.io/qdrant/qdrant:latest + ports: + - 6333:6333 + - 6334:6334 + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + pip install -r requirements.txt pytest + - name: Run integration tests + run: pytest tests/test_integration.py -v + env: + QDRANT_URL: http://localhost:6333 + COLLECTION_NAME: test_mcp_maildir build: runs-on: ubuntu-latest - needs: test + needs: [test, integration-test] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 @@ -44,9 +66,6 @@ jobs: with: images: jcabillot/mcp-maildir tags: | - #type=ref,event=branch - #type=ref,event=pr - #type=sha type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - name: Build and push diff --git a/tests/test_integration.py b/tests/test_integration.py new file mode 100644 index 0000000..43bb19a --- /dev/null +++ b/tests/test_integration.py @@ -0,0 +1,175 @@ +"""Integration tests for mcp-maildir with a real Qdrant instance.""" + +import os +import uuid +import pytest + +os.environ["QDRANT_URL"] = "http://localhost:6333" +os.environ["COLLECTION_NAME"] = "test_mcp_maildir" +os.environ["EMBEDDING_MODEL_NAME"] = "BAAI/bge-small-en-v1.5" + +from server import get_qdrant_client, get_embedding_model, search_emails, read_email +from qdrant_client.http import models + + +TEST_EMAILS = [ + { + "message_id": "", + "date": "2026-01-15T10:00:00", + "sender": "alice@example.com", + "sender_raw": "Alice ", + "receiver": "bob@example.com", + "receiver_raw": "Bob ", + "subject": "Hello World", + "body_text": "This is a test email about Python programming and vector databases.", + "attachments": [], + }, + { + "message_id": "", + "date": "2026-01-16T14:30:00", + "sender": "carol@other.com", + "sender_raw": "Carol ", + "receiver": "alice@example.com", + "receiver_raw": "Alice ", + "subject": "Qdrant setup help", + "body_text": "Can you help me set up Qdrant for semantic email search?", + "attachments": ["screenshot.png"], + }, + { + "message_id": "", + "date": "2026-02-01T09:00:00", + "sender": "bob@example.com", + "sender_raw": "Bob ", + "receiver": "alice@example.com", + "receiver_raw": "Alice ", + "subject": "Meeting next week", + "body_text": "Let's discuss the project roadmap and data pipeline.", + "attachments": [], + }, +] + + +@pytest.fixture(scope="module") +def qdrant_setup(): + """Create collection and index test emails once per test run.""" + client = get_qdrant_client() + model = get_embedding_model() + collection_name = os.environ["COLLECTION_NAME"] + + # Clean up from previous runs + try: + client.delete_collection(collection_name) + except Exception: + pass + + # Create collection + probe = next(iter(model.embed(["dimension_probe"]))) + client.create_collection( + collection_name=collection_name, + vectors_config=models.VectorParams( + size=len(probe), distance=models.Distance.COSINE + ), + ) + + # Index test emails + for email in TEST_EMAILS: + vector_text = ( + f"Date: {email['date']}\n" + f"From: {email['sender']}\n" + f"To: {email['receiver']}\n" + f"Subject: {email['subject']}\n\n" + f"{email['body_text']}\n\n" + f"Attachments: {', '.join(email['attachments']) if email['attachments'] else 'None'}" + ) + vector = list(model.embed([vector_text]))[0].tolist() + + # Create payload indexes on first upsert + client.create_payload_index( + collection_name=collection_name, + field_name="sender", + field_schema=models.PayloadSchemaType.KEYWORD, + ) + client.create_payload_index( + collection_name=collection_name, + field_name="receiver", + field_schema=models.PayloadSchemaType.KEYWORD, + ) + client.create_payload_index( + collection_name=collection_name, + field_name="date", + field_schema=models.PayloadSchemaType.DATETIME, + ) + + client.upsert( + collection_name=collection_name, + points=[ + models.PointStruct( + id=str(uuid.uuid5(uuid.NAMESPACE_OID, email["message_id"])), + vector=vector, + payload={ + "message_id": email["message_id"], + "date": email["date"], + "sender": email["sender"], + "sender_raw": email["sender_raw"], + "receiver": email["receiver"], + "receiver_raw": email["receiver_raw"], + "subject": email["subject"], + "body_text": email["body_text"], + "attachments": email["attachments"], + }, + ) + ], + ) + + yield client + + # Cleanup + try: + client.delete_collection(collection_name) + except Exception: + pass + + +class TestSearchEmails: + def test_search_by_content(self, qdrant_setup): + """Search for emails about Python/programming.""" + result = search_emails(query="Python programming") + assert result["count"] >= 1 + assert "results" in result + assert any("Python" in r.get("body_text", "") for r in result["results"]) + + def test_search_with_participant_filter(self, qdrant_setup): + """Search emails sent by alice.""" + result = search_emails(query="help", participant="alice@example.com") + assert result["count"] >= 1 + messages = [r["message_id"] for r in result["results"]] + assert "" in messages + + def test_search_with_date_filter(self, qdrant_setup): + """Search emails after a specific date.""" + result = search_emails(query="meeting", start_date="2026-02-01") + assert result["count"] >= 1 + for r in result["results"]: + date = r.get("date", "") + assert date >= "2026-02-01" + + def test_search_no_results(self, qdrant_setup): + """Search for something that doesn't exist.""" + result = search_emails(query="gobbledygookxyz123") + assert result["count"] == 0 + assert result["results"] == [] + + +class TestReadEmail: + def test_read_existing_email(self, qdrant_setup): + """Read an email by its message_id.""" + result = read_email(message_id="") + assert "error" not in result + assert result["message_id"] == "" + assert "This is a test email" in result.get("body_text", "") + + def test_read_nonexistent_email(self, qdrant_setup): + """Read an email that doesn't exist.""" + result = read_email(message_id="") + assert "error" in result + assert "No email found" in result["error"] -- 2.52.0 From 655af15121d02b1dc2a8bd7b50a0c152f9e85d6d Mon Sep 17 00:00:00 2001 From: cloudix_mcp_server Date: Fri, 12 Jun 2026 08:08:18 -0400 Subject: [PATCH 4/7] fix: replace Gitea services container with manual docker run for Qdrant The Gitea Actions runner v1.0.8 fails to start the Qdrant service container with 'exec: ./entrypoint.sh: no such file or directory'. This is a runner bug with how it handles service container entrypoints/CMD. Bypass the service container mechanism by starting Qdrant manually with docker run -d and a health check wait loop. --- .gitea/workflows/docker-build.yaml | 80 +----------------------------- 1 file changed, 1 insertion(+), 79 deletions(-) diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 63aa8d8..0dc228c 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -1,79 +1 @@ -name: Docker Build and Push - -on: - pull_request: - branches: [main] - push: - branches: [main] - schedule: - - cron: '0 0 * * *' - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install dependencies - run: | - pip install -r requirements.txt pytest - - name: Run unit tests - run: pytest tests/ -v --ignore=tests/test_integration.py - - integration-test: - runs-on: ubuntu-latest - services: - qdrant: - image: docker.io/qdrant/qdrant:latest - ports: - - 6333:6333 - - 6334:6334 - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - uses: actions/setup-python@v6 - with: - python-version: "3.13" - - name: Install dependencies - run: | - pip install -r requirements.txt pytest - - name: Run integration tests - run: pytest tests/test_integration.py -v - env: - QDRANT_URL: http://localhost:6333 - COLLECTION_NAME: test_mcp_maildir - - build: - runs-on: ubuntu-latest - needs: [test, integration-test] - steps: - - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - - - name: Login to Docker Hub - if: github.event_name != 'pull_request' - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Docker metadata - id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 - with: - images: jcabillot/mcp-maildir - tags: | - type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} - - - name: Build and push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 - with: - context: . - file: pkg/Dockerfile - push: ${{ github.event_name != 'pull_request' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - pull: true +bmFtZTogRG9ja2VyIEJ1aWxkIGFuZCBQdXNoCgpvbjoKICBwdWxsX3JlcXVlc3Q6CiAgICBicmFuY2hlczogW21haW5dCiAgcHVzaDoKICAgIGJyYW5jaGVzOiBbbWFpbl0KICBzY2hlZHVsZToKICAgIC0gY3JvbjogJzAgMCAqICogKicKCmpvYnM6CiAgdGVzdDoKICAgIHJ1bnMtb246IHVidW50dS1sYXRlc3QKICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAZGY0Y2IxYzA2OWUxODc0ZWRkMzFiNDMxMWYxODg0MTcyY2VjMGUxMCAjIHY2CiAgICAgIC0gdXNlczogYWN0aW9ucy9zZXR1cC1weXRob25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHl0aG9uLXZlcnNpb246ICIzLjEzIgogICAgICAtIG5hbWU6IEluc3RhbGwgZGVwZW5kZW5jaWVzCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0IHB5dGVzdAogICAgICAtIG5hbWU6IFJ1biB1bml0IHRlc3RzCiAgICAgICAgcnVuOiBweXRlc3QgdGVzdHMvIC12IC0taWdub3JlPXRlc3RzL3Rlc3RfaW50ZWdyYXRpb24ucHkKCiAgaW50ZWdyYXRpb24tdGVzdDoKICAgIHJ1bnMtb246IHVidW50dS1sYXRlc3QKICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAZGY0Y2IxYzA2OWUxODc0ZWRkMzFiNDMxMWYxODg0MTcyY2VjMGUxMCAjIHY2CgogICAgICAtIG5hbWU6IFN0YXJ0IFFkcmFudAogICAgICAgIHJ1bjogfAogICAgICAgICAgZG9ja2VyIHJ1biAtZCAtLW5hbWUgcWRyYW50IFwKICAgICAgICAgICAgLXAgNjMzMzo2MzMzIC1wIDYzMzQ6NjMzNCBcCiAgICAgICAgICAgIGRvY2tlci5pby9xZHJhbnQvcWRyYW50OmxhdGVzdAoKICAgICAgLSBuYW1lOiBXYWl0IGZvciBRZHJhbnQKICAgICAgICBydW46IHwKICAgICAgICAgIGZvciBpIGluICQoc2VxIDEgMSAzMCk7IGRvCiAgICAgICAgICAgIGN1cmwgLXMgaHR0cDovL2xvY2FsaG9zdDo2MzMzL2hlYWx0aHogJiYgZWNobyAiUURSQU5UIHJlYWR5IiAmJiBicmVhawogICAgICAgICAgICBlY2hvICJXYWl0aW5nIGZvciBRZHJhbnQuLi4gKCRpLzMwKSIKICAgICAgICAgICAgc2xlZXAgMQogICAgICAgICAgZG9uZQoKICAgICAgLSB1c2VzOiBhY3Rpb25zL3NldHVwLXB5dGhvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBweXRob24tdmVyc2lvbjogIjMuMTMiCiAgICAgIC0gbmFtZTogSW5zdGFsbCBkZXBlbmRlbmNpZXMKICAgICAgICBydW46IHwKICAgICAgICAgIHBpcCBpbnN0YWxsIC1yIHJlcXVpcmVtZW50cy50eHQgcHl0ZXN0CiAgICAgIC0gbmFtZTogUnVuIGludGVncmF0aW9uIHRlc3RzCiAgICAgICAgcnVuOiBweXRlc3QgdGVzdHMvdGVzdF9pbnRlZ3JhdGlvbi5weSAtdgogICAgICAgIGVudjoKICAgICAgICAgIFFEUkFOVF9VUkw6IGh0dHA6Ly9sb2NhbGhvc3Q6NjMzMwogICAgICAgICAgQ09MTEVDVElPTl9OQU1FOiB0ZXN0X21jcF9tYWlsZGlyCgogIGJ1aWxkOgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgbmVlZHM6IFt0ZXN0LCBpbnRlZ3JhdGlvbi10ZXN0XQogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEBkZjRjYjFjMDY5ZTE4NzRlZGQzMWI0MzExZjE4ODQxNzJjZWMwZTEwICMgdjYKCiAgICAgIC0gbmFtZTogU2V0IHVwIERvY2tlciBCdWlsZHgKICAgICAgICB1c2VzOiBkb2NrZXIvc2V0dXAtYnVpbGR4LWFjdGlvbkBkN2Y1ZTdmNTA5ZTQ1Y2VjNWM3NmM0ZDVhZmRkN2RlOTNkMGIzZGY1ICMgdjQKCiAgICAgIC0gbmFtZTogTG9naW4gdG8gRG9ja2VyIEh1YgogICAgICAgIGlmOiBnaXRodWIuZXZlbnRfbmFtZSAhPSAncHVsbF9yZXF1ZXN0JwogICAgICAgIHVzZXM6IGRvY2tlci9sb2dpbi1hY3Rpb25ANjUwMDA2YzZlYjdkYmE3M2E5OTVjYzAzYjBiMmQ3ZjVjYTkxNWJlZSAjIHY0CiAgICAgICAgd2l0aDoKICAgICAgICAgIHVzZXJuYW1lOiAke3sgc2VjcmV0cy5ET0NLRVJIVUJfVVNFUk5BTUUgfX0KICAgICAgICAgIHBhc3N3b3JkOiAke3sgc2VjcmV0cy5ET0NLRVJIVUJfVE9LRU4gfX0KCiAgICAgIC0gbmFtZTogRG9ja2VyIG1ldGFkYXRhCiAgICAgICAgaWQ6IG1ldGEKICAgICAgICB1c2VzOiBkb2NrZXIvbWV0YWRhdGEtYWN0aW9uQDgwYzdlOTRkZDliOTMxOWJkNWViN2EwZTBmZTkyOTFlMjNhMmEyZTkgIyB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBpbWFnZXM6IGpjYWJpbGxvdC9tY3AtbWFpbGRpcgogICAgICAgICAgdGFnczogfAogICAgICAgICAgICB0eXBlPXJhdyx2YWx1ZT1sYXRlc3QsZW5hYmxlPSR7eyBnaXRodWIucmVmID09ICdyZWZzL2hlYWRzL21haW4nIH19CgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoCiAgICAgICAgdXNlczogZG9ja2VyL2J1aWxkLXB1c2gtYWN0aW9uQGY5ZjMwNDJmN2UyNzg5NTg2NjEwZDZlOGI4NWM4ZjAzZTUxOTViYWYgIyB2NwogICAgICAgIHdpdGg6CiAgICAgICAgICBjb250ZXh0OiAuCiAgICAgICAgICBmaWxlOiBwa2cvRG9ja2VyZmlsZQogICAgICAgICAgcHVzaDogJHt7IGdpdGh1Yi5ldmVudF9uYW1lICE9ICdwdWxsX3JlcXVlc3QnIH19CiAgICAgICAgICB0YWdzOiAke3sgc3RlcHMubWV0YS5vdXRwdXRzLnRhZ3MgfX0KICAgICAgICAgIGxhYmVsczogJHt7IHN0ZXBzLm1ldGEub3V0cHV0cy5sYWJlbHMgfX0KICAgICAgICAgIHB1bGw6IHRydWUK \ No newline at end of file -- 2.52.0 From 14650619adf98f141e6d954abd360ce440809c59 Mon Sep 17 00:00:00 2001 From: cloudix_mcp_server Date: Fri, 12 Jun 2026 08:10:51 -0400 Subject: [PATCH 5/7] fix: replace Gitea services container with manual docker run for Qdrant The Gitea Actions runner v1.0.8 fails to start the Qdrant service container with 'exec: ./entrypoint.sh: no such file or directory'. This is a runner bug with how it handles service container entrypoints/CMD. Bypass the service container mechanism by starting Qdrant manually with docker run -d and a health check wait loop. --- .gitea/workflows/docker-build.yaml | 89 +++++++++++++++++++++++++++++- 1 file changed, 88 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 0dc228c..4af603c 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -1 +1,88 @@ -bmFtZTogRG9ja2VyIEJ1aWxkIGFuZCBQdXNoCgpvbjoKICBwdWxsX3JlcXVlc3Q6CiAgICBicmFuY2hlczogW21haW5dCiAgcHVzaDoKICAgIGJyYW5jaGVzOiBbbWFpbl0KICBzY2hlZHVsZToKICAgIC0gY3JvbjogJzAgMCAqICogKicKCmpvYnM6CiAgdGVzdDoKICAgIHJ1bnMtb246IHVidW50dS1sYXRlc3QKICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAZGY0Y2IxYzA2OWUxODc0ZWRkMzFiNDMxMWYxODg0MTcyY2VjMGUxMCAjIHY2CiAgICAgIC0gdXNlczogYWN0aW9ucy9zZXR1cC1weXRob25AdjYKICAgICAgICB3aXRoOgogICAgICAgICAgcHl0aG9uLXZlcnNpb246ICIzLjEzIgogICAgICAtIG5hbWU6IEluc3RhbGwgZGVwZW5kZW5jaWVzCiAgICAgICAgcnVuOiB8CiAgICAgICAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0IHB5dGVzdAogICAgICAtIG5hbWU6IFJ1biB1bml0IHRlc3RzCiAgICAgICAgcnVuOiBweXRlc3QgdGVzdHMvIC12IC0taWdub3JlPXRlc3RzL3Rlc3RfaW50ZWdyYXRpb24ucHkKCiAgaW50ZWdyYXRpb24tdGVzdDoKICAgIHJ1bnMtb246IHVidW50dS1sYXRlc3QKICAgIHN0ZXBzOgogICAgICAtIHVzZXM6IGFjdGlvbnMvY2hlY2tvdXRAZGY0Y2IxYzA2OWUxODc0ZWRkMzFiNDMxMWYxODg0MTcyY2VjMGUxMCAjIHY2CgogICAgICAtIG5hbWU6IFN0YXJ0IFFkcmFudAogICAgICAgIHJ1bjogfAogICAgICAgICAgZG9ja2VyIHJ1biAtZCAtLW5hbWUgcWRyYW50IFwKICAgICAgICAgICAgLXAgNjMzMzo2MzMzIC1wIDYzMzQ6NjMzNCBcCiAgICAgICAgICAgIGRvY2tlci5pby9xZHJhbnQvcWRyYW50OmxhdGVzdAoKICAgICAgLSBuYW1lOiBXYWl0IGZvciBRZHJhbnQKICAgICAgICBydW46IHwKICAgICAgICAgIGZvciBpIGluICQoc2VxIDEgMSAzMCk7IGRvCiAgICAgICAgICAgIGN1cmwgLXMgaHR0cDovL2xvY2FsaG9zdDo2MzMzL2hlYWx0aHogJiYgZWNobyAiUURSQU5UIHJlYWR5IiAmJiBicmVhawogICAgICAgICAgICBlY2hvICJXYWl0aW5nIGZvciBRZHJhbnQuLi4gKCRpLzMwKSIKICAgICAgICAgICAgc2xlZXAgMQogICAgICAgICAgZG9uZQoKICAgICAgLSB1c2VzOiBhY3Rpb25zL3NldHVwLXB5dGhvbkB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBweXRob24tdmVyc2lvbjogIjMuMTMiCiAgICAgIC0gbmFtZTogSW5zdGFsbCBkZXBlbmRlbmNpZXMKICAgICAgICBydW46IHwKICAgICAgICAgIHBpcCBpbnN0YWxsIC1yIHJlcXVpcmVtZW50cy50eHQgcHl0ZXN0CiAgICAgIC0gbmFtZTogUnVuIGludGVncmF0aW9uIHRlc3RzCiAgICAgICAgcnVuOiBweXRlc3QgdGVzdHMvdGVzdF9pbnRlZ3JhdGlvbi5weSAtdgogICAgICAgIGVudjoKICAgICAgICAgIFFEUkFOVF9VUkw6IGh0dHA6Ly9sb2NhbGhvc3Q6NjMzMwogICAgICAgICAgQ09MTEVDVElPTl9OQU1FOiB0ZXN0X21jcF9tYWlsZGlyCgogIGJ1aWxkOgogICAgcnVucy1vbjogdWJ1bnR1LWxhdGVzdAogICAgbmVlZHM6IFt0ZXN0LCBpbnRlZ3JhdGlvbi10ZXN0XQogICAgc3RlcHM6CiAgICAgIC0gdXNlczogYWN0aW9ucy9jaGVja291dEBkZjRjYjFjMDY5ZTE4NzRlZGQzMWI0MzExZjE4ODQxNzJjZWMwZTEwICMgdjYKCiAgICAgIC0gbmFtZTogU2V0IHVwIERvY2tlciBCdWlsZHgKICAgICAgICB1c2VzOiBkb2NrZXIvc2V0dXAtYnVpbGR4LWFjdGlvbkBkN2Y1ZTdmNTA5ZTQ1Y2VjNWM3NmM0ZDVhZmRkN2RlOTNkMGIzZGY1ICMgdjQKCiAgICAgIC0gbmFtZTogTG9naW4gdG8gRG9ja2VyIEh1YgogICAgICAgIGlmOiBnaXRodWIuZXZlbnRfbmFtZSAhPSAncHVsbF9yZXF1ZXN0JwogICAgICAgIHVzZXM6IGRvY2tlci9sb2dpbi1hY3Rpb25ANjUwMDA2YzZlYjdkYmE3M2E5OTVjYzAzYjBiMmQ3ZjVjYTkxNWJlZSAjIHY0CiAgICAgICAgd2l0aDoKICAgICAgICAgIHVzZXJuYW1lOiAke3sgc2VjcmV0cy5ET0NLRVJIVUJfVVNFUk5BTUUgfX0KICAgICAgICAgIHBhc3N3b3JkOiAke3sgc2VjcmV0cy5ET0NLRVJIVUJfVE9LRU4gfX0KCiAgICAgIC0gbmFtZTogRG9ja2VyIG1ldGFkYXRhCiAgICAgICAgaWQ6IG1ldGEKICAgICAgICB1c2VzOiBkb2NrZXIvbWV0YWRhdGEtYWN0aW9uQDgwYzdlOTRkZDliOTMxOWJkNWViN2EwZTBmZTkyOTFlMjNhMmEyZTkgIyB2NgogICAgICAgIHdpdGg6CiAgICAgICAgICBpbWFnZXM6IGpjYWJpbGxvdC9tY3AtbWFpbGRpcgogICAgICAgICAgdGFnczogfAogICAgICAgICAgICB0eXBlPXJhdyx2YWx1ZT1sYXRlc3QsZW5hYmxlPSR7eyBnaXRodWIucmVmID09ICdyZWZzL2hlYWRzL21haW4nIH19CgogICAgICAtIG5hbWU6IEJ1aWxkIGFuZCBwdXNoCiAgICAgICAgdXNlczogZG9ja2VyL2J1aWxkLXB1c2gtYWN0aW9uQGY5ZjMwNDJmN2UyNzg5NTg2NjEwZDZlOGI4NWM4ZjAzZTUxOTViYWYgIyB2NwogICAgICAgIHdpdGg6CiAgICAgICAgICBjb250ZXh0OiAuCiAgICAgICAgICBmaWxlOiBwa2cvRG9ja2VyZmlsZQogICAgICAgICAgcHVzaDogJHt7IGdpdGh1Yi5ldmVudF9uYW1lICE9ICdwdWxsX3JlcXVlc3QnIH19CiAgICAgICAgICB0YWdzOiAke3sgc3RlcHMubWV0YS5vdXRwdXRzLnRhZ3MgfX0KICAgICAgICAgIGxhYmVsczogJHt7IHN0ZXBzLm1ldGEub3V0cHV0cy5sYWJlbHMgfX0KICAgICAgICAgIHB1bGw6IHRydWUK \ No newline at end of file +name: Docker Build and Push + +on: + pull_request: + branches: [main] + push: + branches: [main] + schedule: + - cron: '0 0 * * *' + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + pip install -r requirements.txt pytest + - name: Run unit tests + run: pytest tests/ -v --ignore=tests/test_integration.py + + integration-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Start Qdrant + run: | + docker run -d --name qdrant \ + -p 6333:6333 -p 6334:6334 \ + docker.io/qdrant/qdrant:latest + + - name: Wait for Qdrant + run: | + for i in $(seq 1 30); do + curl -s http://localhost:6333/healthz && echo "QDRANT ready" && break + echo "Waiting for Qdrant... ($i/30)" + sleep 1 + done + + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - name: Install dependencies + run: | + pip install -r requirements.txt pytest + - name: Run integration tests + run: pytest tests/test_integration.py -v + env: + QDRANT_URL: http://localhost:6333 + COLLECTION_NAME: test_mcp_maildir + + build: + runs-on: ubuntu-latest + needs: [test, integration-test] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 + + - name: Login to Docker Hub + if: github.event_name != 'pull_request' + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Docker metadata + id: meta + uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6 + with: + images: jcabillot/mcp-maildir + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + file: pkg/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + pull: true -- 2.52.0 From d784cd9fbac815f2f333420de18cab750532de0e Mon Sep 17 00:00:00 2001 From: cloudix_mcp_server Date: Fri, 12 Jun 2026 08:14:35 -0400 Subject: [PATCH 6/7] fix: share job container network with Qdrant docker run -d publishes ports to the Docker bridge network, but the job container runs on a different Gitea Actions network. This causes Connection refused when tests try http://localhost:6333. Use --network container:$(hostname) so Qdrant shares the job container's network stack, making localhost reachable. --- .gitea/workflows/docker-build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/docker-build.yaml b/.gitea/workflows/docker-build.yaml index 4af603c..48cd4ca 100644 --- a/.gitea/workflows/docker-build.yaml +++ b/.gitea/workflows/docker-build.yaml @@ -30,7 +30,7 @@ jobs: - name: Start Qdrant run: | docker run -d --name qdrant \ - -p 6333:6333 -p 6334:6334 \ + --network "container:$(hostname)" \ docker.io/qdrant/qdrant:latest - name: Wait for Qdrant -- 2.52.0 From 30ac4ae9ca73be8aa41c0f67b768aba5b5a210de Mon Sep 17 00:00:00 2001 From: cloudix_mcp_server Date: Fri, 12 Jun 2026 08:25:52 -0400 Subject: [PATCH 7/7] fix: correct integration test assertions for semantic search - test_search_by_content: format_search_result() does not include body_text, so check for the expected message_id instead. - test_search_no_results: vector cosine similarity always returns nearest neighbors; use a date filter far in the future to guarantee zero results instead. --- tests/test_integration.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/test_integration.py b/tests/test_integration.py index 43bb19a..dd53c7f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -132,11 +132,17 @@ def qdrant_setup(): class TestSearchEmails: def test_search_by_content(self, qdrant_setup): - """Search for emails about Python/programming.""" + """Semantic search returns the most relevant email first. + + Searching for 'Python programming' should match email 001 + which discusses Python and vector databases. + """ result = search_emails(query="Python programming") assert result["count"] >= 1 assert "results" in result - assert any("Python" in r.get("body_text", "") for r in result["results"]) + # The top result should be email 001 (most semantically relevant) + messages = [r["message_id"] for r in result["results"]] + assert "" in messages def test_search_with_participant_filter(self, qdrant_setup): """Search emails sent by alice.""" @@ -153,9 +159,17 @@ class TestSearchEmails: date = r.get("date", "") assert date >= "2026-02-01" - def test_search_no_results(self, qdrant_setup): - """Search for something that doesn't exist.""" - result = search_emails(query="gobbledygookxyz123") + def test_search_no_results_by_date(self, qdrant_setup): + """Search with a filter that matches no emails returns empty results. + + With semantic (vector) search, any text query always has nearest + neighbors, so we use a date filter that matches nothing instead. + """ + result = search_emails( + query="anything", + start_date="2099-01-01", + end_date="2099-12-31", + ) assert result["count"] == 0 assert result["results"] == [] -- 2.52.0