diff --git a/.env.example b/.env.example index 5292b78..58f2855 100644 --- a/.env.example +++ b/.env.example @@ -3,8 +3,9 @@ # Database DATABASE_URL=postgresql://postgres:postgres@localhost:5432/agentsuite -# AWS SES Credentials +# AWS Credentials AWS_ACCESS_KEY_ID=your_aws_key AWS_SECRET_ACCESS_KEY=your_aws_secret AWS_REGION=us-east-1 SES_FROM_EMAIL=noreply@agents.dev +S3_BUCKET=your_attachment_bucket diff --git a/app/core/config.py b/app/core/config.py index df3160a..834db57 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -3,19 +3,17 @@ class Settings(BaseSettings): - # Database database_url: str = "postgresql://postgres:postgres@localhost:5432/agentsuite" - - # AWS SES + aws_access_key_id: str = "" aws_secret_access_key: str = "" aws_region: str = "us-east-1" ses_from_email: str = "" - - # App + s3_bucket: str = "" + app_name: str = "Agent Suite" debug: bool = False - + class Config: env_file = ".env" diff --git a/app/main.py b/app/main.py index 5bd191b..85ab374 100644 --- a/app/main.py +++ b/app/main.py @@ -1,8 +1,10 @@ -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, Form, UploadFile, File from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional import boto3 +import json +import uuid from botocore.exceptions import ClientError from app.core.config import get_settings @@ -35,6 +37,54 @@ def verify_api_key(credentials: HTTPAuthorizationCredentials = Depends(security) return inbox +def _passed(value: str) -> bool: + normalized = (value or "").strip().lower() + return normalized in {"pass", "passed", "true", "yes", "1", "neutral"} + + +def _upload_attachments(files: Optional[List[UploadFile]]) -> List[dict]: + if not files: + return [] + + attachment_meta = [] + s3_enabled = bool( + settings.s3_bucket + and settings.aws_access_key_id + and settings.aws_secret_access_key + ) + s3_client = None + if s3_enabled: + s3_client = boto3.client( + "s3", + aws_access_key_id=settings.aws_access_key_id, + aws_secret_access_key=settings.aws_secret_access_key, + region_name=settings.aws_region, + ) + + for upload in files: + body = upload.file.read() + meta = { + "filename": upload.filename, + "content_type": upload.content_type, + "size": len(body), + } + if s3_client: + key = f"mail-attachments/{uuid.uuid4().hex}-{upload.filename}" + s3_client.put_object( + Bucket=settings.s3_bucket, + Key=key, + Body=body, + ContentType=upload.content_type or "application/octet-stream", + ) + meta["storage"] = "s3" + meta["bucket"] = settings.s3_bucket + meta["key"] = key + else: + meta["storage"] = "inline" + attachment_meta.append(meta) + return attachment_meta + + @app.get("/health") def health_check(): return {"status": "ok", "service": "agent-suite"} @@ -43,17 +93,14 @@ def health_check(): @app.post("/v1/inboxes", response_model=schemas.InboxResponse, status_code=status.HTTP_201_CREATED) def create_inbox(db: Session = Depends(get_db)): """Create a new inbox. Returns email address and API key.""" - import uuid - - # Generate unique email address unique_id = uuid.uuid4().hex[:12] email_address = f"{unique_id}@agents.dev" - + inbox = models.Inbox(email_address=email_address) db.add(inbox) db.commit() db.refresh(inbox) - + return inbox @@ -75,7 +122,7 @@ def send_email( status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="AWS SES not configured" ) - + try: ses_client = boto3.client( 'ses', @@ -83,7 +130,7 @@ def send_email( aws_secret_access_key=settings.aws_secret_access_key, region_name=settings.aws_region ) - + response = ses_client.send_email( Source=settings.ses_from_email or inbox.email_address, Destination={'ToAddresses': [message.to]}, @@ -95,8 +142,7 @@ def send_email( } } ) - - # Store sent message + sent_msg = models.Message( inbox_id=inbox.id, sender=inbox.email_address, @@ -107,13 +153,13 @@ def send_email( ) db.add(sent_msg) db.commit() - + return { "status": "sent", "message_id": response['MessageId'], "to": message.to } - + except ClientError as e: raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, @@ -131,38 +177,48 @@ def list_messages( ): """List received messages for this inbox.""" query = db.query(models.Message).filter(models.Message.inbox_id == inbox.id) - + if unread_only: query = query.filter(models.Message.is_read == False) - + total = query.count() messages = query.order_by(models.Message.received_at.desc()).offset(skip).limit(limit).all() - + return schemas.MessageList(total=total, messages=messages) @app.post("/v1/webhooks/mailgun") def mailgun_webhook( - sender: str, - recipient: str, - subject: str = "", - body_plain: str = "", - body_html: str = "", - message_id: str = "", + sender: str = Form(...), + recipient: str = Form(...), + subject: str = Form(""), + body_plain: str = Form(""), + body_html: str = Form(""), + message_id: str = Form(""), + dkim: str = Form(""), + SPF: str = Form(""), + spam_score: str = Form("0"), + attachment_1: Optional[UploadFile] = File(None), + attachment_2: Optional[UploadFile] = File(None), + attachment_3: Optional[UploadFile] = File(None), db: Session = Depends(get_db) ): - """Receive incoming email from Mailgun.""" - # Find inbox by recipient email + """Receive incoming email from Mailgun with lightweight verification metadata.""" inbox = db.query(models.Inbox).filter( models.Inbox.email_address == recipient, models.Inbox.is_active == True ).first() - + if not inbox: - # Silently drop - inbox doesn't exist or inactive return {"status": "dropped"} - - # Store message + + score = float(spam_score or 0) + if score > 5: + return {"status": "rejected", "reason": "spam", "spam_score": score} + + attachments = [f for f in [attachment_1, attachment_2, attachment_3] if f is not None] + attachment_meta = _upload_attachments(attachments) + message = models.Message( inbox_id=inbox.id, sender=sender, @@ -170,11 +226,20 @@ def mailgun_webhook( subject=subject, body_text=body_plain, body_html=body_html, - message_id=message_id + message_id=message_id, + attachments_meta=json.dumps(attachment_meta), + spam_score=str(score), + dkim_passed=_passed(dkim), + spf_passed=_passed(SPF), ) db.add(message) db.commit() - - # TODO: Trigger user webhook if configured - - return {"status": "received", "message_id": str(message.id)} + + return { + "status": "received", + "message_id": str(message.id), + "attachments": attachment_meta, + "dkim_passed": message.dkim_passed, + "spf_passed": message.spf_passed, + "spam_score": score, + } diff --git a/app/models/models.py b/app/models/models.py index 7a21b6f..b2c1ee1 100644 --- a/app/models/models.py +++ b/app/models/models.py @@ -1,7 +1,6 @@ import uuid from datetime import datetime -from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean -from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Uuid from app.db.database import Base @@ -11,8 +10,8 @@ def generate_api_key(): class Inbox(Base): __tablename__ = "inboxes" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) email_address = Column(String(255), unique=True, index=True, nullable=False) api_key = Column(String(255), unique=True, index=True, default=generate_api_key) created_at = Column(DateTime, default=datetime.utcnow) @@ -21,9 +20,9 @@ class Inbox(Base): class Message(Base): __tablename__ = "messages" - - id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) - inbox_id = Column(UUID(as_uuid=True), ForeignKey("inboxes.id"), index=True) + + id = Column(Uuid(as_uuid=True), primary_key=True, default=uuid.uuid4) + inbox_id = Column(Uuid(as_uuid=True), ForeignKey("inboxes.id"), index=True) sender = Column(String(255), nullable=False) recipient = Column(String(255), nullable=False) subject = Column(String(500)) @@ -31,5 +30,9 @@ class Message(Base): body_html = Column(Text) received_at = Column(DateTime, default=datetime.utcnow) is_read = Column(Boolean, default=False) - message_id = Column(String(255), index=True) # External message ID - raw_data = Column(Text) # Store raw email for debugging + message_id = Column(String(255), index=True) + raw_data = Column(Text) + attachments_meta = Column(Text) + spam_score = Column(String(50)) + dkim_passed = Column(Boolean) + spf_passed = Column(Boolean) diff --git a/app/schemas/schemas.py b/app/schemas/schemas.py index 6bda3c6..bbe151d 100644 --- a/app/schemas/schemas.py +++ b/app/schemas/schemas.py @@ -4,9 +4,8 @@ from pydantic import BaseModel, EmailStr -# Inbox schemas class InboxCreate(BaseModel): - pass # No fields needed - we generate everything + pass class InboxResponse(BaseModel): @@ -14,7 +13,7 @@ class InboxResponse(BaseModel): email_address: str api_key: str created_at: datetime - + class Config: from_attributes = True @@ -23,12 +22,11 @@ class InboxPublic(BaseModel): id: UUID email_address: str created_at: datetime - + class Config: from_attributes = True -# Message schemas class MessageCreate(BaseModel): to: EmailStr subject: str @@ -44,7 +42,11 @@ class MessageResponse(BaseModel): body_text: Optional[str] received_at: datetime is_read: bool - + attachments_meta: Optional[str] = None + spam_score: Optional[str] = None + dkim_passed: Optional[bool] = None + spf_passed: Optional[bool] = None + class Config: from_attributes = True diff --git a/requirements.txt b/requirements.txt index 7633720..c63754c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,3 +9,5 @@ boto3==1.34.0 python-multipart==0.0.6 pytest==7.4.4 httpx==0.26.0 +email-validator==2.2.0 +dkimpy==1.1.8 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..b941d36 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,3 @@ +import os + +os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db") diff --git a/tests/test_api.py b/tests/test_api.py index 08f4976..cbc2850 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1,3 +1,4 @@ +import io import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine @@ -6,7 +7,6 @@ from app.main import app, get_db from app.db.database import Base -# Test database SQLALCHEMY_DATABASE_URL = "sqlite:///./test.db" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) @@ -48,11 +48,9 @@ def test_create_inbox(setup_db): def test_get_my_inbox(setup_db): - # Create inbox create_resp = client.post("/v1/inboxes") api_key = create_resp.json()["api_key"] - - # Get inbox with API key + response = client.get( "/v1/inboxes/me", headers={"Authorization": f"Bearer {api_key}"} @@ -71,12 +69,10 @@ def test_invalid_api_key(setup_db): def test_list_messages(setup_db): - # Create inbox create_resp = client.post("/v1/inboxes") api_key = create_resp.json()["api_key"] email = create_resp.json()["email_address"] - - # Simulate incoming message via webhook + client.post( "/v1/webhooks/mailgun", data={ @@ -84,11 +80,13 @@ def test_list_messages(setup_db): "recipient": email, "subject": "Test Subject", "body_plain": "Test body", - "message_id": "test123" + "message_id": "test123", + "dkim": "pass", + "SPF": "pass", + "spam_score": "1.5", } ) - - # List messages + response = client.get( "/v1/inboxes/me/messages", headers={"Authorization": f"Bearer {api_key}"} @@ -97,3 +95,61 @@ def test_list_messages(setup_db): data = response.json() assert data["total"] == 1 assert data["messages"][0]["subject"] == "Test Subject" + assert data["messages"][0]["dkim_passed"] is True + assert data["messages"][0]["spf_passed"] is True + assert data["messages"][0]["spam_score"] == "1.5" + + +def test_reject_spam_message(setup_db): + create_resp = client.post("/v1/inboxes") + email = create_resp.json()["email_address"] + + response = client.post( + "/v1/webhooks/mailgun", + data={ + "sender": "spam@example.com", + "recipient": email, + "subject": "Spam", + "body_plain": "Buy now", + "message_id": "spam123", + "spam_score": "8.4", + } + ) + assert response.status_code == 200 + assert response.json()["status"] == "rejected" + assert response.json()["reason"] == "spam" + + +def test_attachment_metadata_is_returned(setup_db): + create_resp = client.post("/v1/inboxes") + api_key = create_resp.json()["api_key"] + email = create_resp.json()["email_address"] + + response = client.post( + "/v1/webhooks/mailgun", + data={ + "sender": "files@example.com", + "recipient": email, + "subject": "Files", + "body_plain": "See attachment", + "message_id": "file123", + "dkim": "pass", + "SPF": "pass", + "spam_score": "0.2", + }, + files={ + "attachment_1": ("hello.txt", io.BytesIO(b"hello world"), "text/plain"), + }, + ) + assert response.status_code == 200 + assert response.json()["status"] == "received" + assert response.json()["attachments"][0]["filename"] == "hello.txt" + + response = client.get( + "/v1/inboxes/me/messages", + headers={"Authorization": f"Bearer {api_key}"} + ) + assert response.status_code == 200 + data = response.json() + assert data["messages"][0]["attachments_meta"] is not None + assert "hello.txt" in data["messages"][0]["attachments_meta"]