Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,13 @@ AWS_ACCESS_KEY_ID=your_aws_key
AWS_SECRET_ACCESS_KEY=your_aws_secret
AWS_REGION=us-east-1
[email protected]

# AWS S3 (Attachment Storage)
S3_BUCKET=your-attachments-bucket
S3_PREFIX=attachments

# Email Verification
SPAM_SCORE_THRESHOLD=5.0
MAILGUN_SIGNING_KEY=your_mailgun_signing_key
REQUIRE_DKIM=true
REQUIRE_SPF=true
Binary file added app/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added app/__pycache__/main.cpython-313.pyc
Binary file not shown.
Binary file added app/core/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added app/core/__pycache__/config.cpython-313.pyc
Binary file not shown.
16 changes: 13 additions & 3 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,27 @@
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 = ""


# AWS S3 (attachments)
s3_bucket: str = ""
s3_prefix: str = "attachments"

# Email Verification
spam_score_threshold: float = 5.0
mailgun_signing_key: str = ""
require_dkim: bool = True
require_spf: bool = True

# App
app_name: str = "Agent Suite"
debug: bool = False

class Config:
env_file = ".env"

Expand Down
Binary file added app/db/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added app/db/__pycache__/database.cpython-313.pyc
Binary file not shown.
137 changes: 116 additions & 21 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,21 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from typing import List
import logging
from typing import List, Optional

import boto3
from botocore.exceptions import ClientError
from fastapi import FastAPI, Depends, HTTPException, Request, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from sqlalchemy.orm import Session
from starlette.datastructures import UploadFile

from app.core.config import get_settings
from app.db.database import get_db, engine, Base
from app.models import models
from app.schemas import schemas
from app.services.email_verification import verify_email, verify_mailgun_webhook_signature
from app.services.attachment_service import parse_and_store_attachments, get_s3_client

logger = logging.getLogger(__name__)

# Create tables
Base.metadata.create_all(bind=engine)
Expand Down Expand Up @@ -142,39 +149,127 @@ def list_messages(


@app.post("/v1/webhooks/mailgun")
def mailgun_webhook(
sender: str,
recipient: str,
subject: str = "",
body_plain: str = "",
body_html: str = "",
message_id: str = "",
db: Session = Depends(get_db)
async def mailgun_webhook(
request: Request,
db: Session = Depends(get_db),
):
"""Receive incoming email from Mailgun."""
"""Receive incoming email from Mailgun with verification and attachment handling.

Performs the following checks on each incoming email:
1. Verifies Mailgun webhook signature (if signing key is configured)
2. Validates SPF/DKIM authentication results
3. Filters spam based on configurable score threshold (default > 5)
4. Parses and stores attachments in S3

Emails that fail spam filtering are rejected with a 400 response.
"""
# Parse form data (Mailgun sends multipart/form-data)
form = await request.form()

sender = form.get("sender", "")
recipient = form.get("recipient", "")
subject = form.get("subject", "")
body_plain = form.get("body-plain", "") or form.get("body_plain", "")
body_html = form.get("body-html", "") or form.get("body_html", "")
message_id = form.get("Message-Id", "") or form.get("message_id", "")

# Mailgun webhook signature verification
if settings.mailgun_signing_key:
timestamp = form.get("timestamp", "")
token = form.get("token", "")
signature = form.get("signature", "")
if not verify_mailgun_webhook_signature(
settings.mailgun_signing_key, timestamp, token, signature
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid webhook signature",
)

# Email verification (SPF/DKIM/Spam)
spf_header = form.get("X-Mailgun-Spf", "")
dkim_header = form.get("X-Mailgun-Dkim-Check-Result", "")
spam_score_header = form.get("X-Mailgun-SSscore", "") or form.get("spam-score", "")

verification = verify_email(
spf_header=spf_header,
dkim_header=dkim_header,
spam_score_header=spam_score_header,
spam_threshold=settings.spam_score_threshold,
)

# Reject spam
if verification.is_spam:
logger.warning(
"Rejected spam email from %s (score: %s)",
sender,
verification.spam_score,
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Email rejected: {verification.rejection_reason}",
)

# Find inbox by recipient email
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

# Store message with verification data
message = models.Message(
inbox_id=inbox.id,
sender=sender,
recipient=recipient,
subject=subject,
body_text=body_plain,
body_html=body_html,
message_id=message_id
message_id=message_id,
spf_pass=verification.spf_pass,
dkim_pass=verification.dkim_pass,
spam_score=verification.spam_score,
is_verified=verification.is_verified,
)
db.add(message)
db.flush() # Get the message ID without committing

# Handle attachments
attachment_count = int(form.get("attachment-count", "0") or "0")
if attachment_count > 0:
files = []
for i in range(1, attachment_count + 1):
file = form.get(f"attachment-{i}")
if file and isinstance(file, UploadFile):
files.append(file)

if files:
s3_client = None
if settings.s3_bucket and settings.aws_access_key_id:
s3_client = get_s3_client(
settings.aws_access_key_id,
settings.aws_secret_access_key,
settings.aws_region,
)

await parse_and_store_attachments(
files=files,
message_id=message.id,
db=db,
s3_client=s3_client,
bucket=settings.s3_bucket,
s3_prefix=settings.s3_prefix,
)

db.commit()

# TODO: Trigger user webhook if configured

return {"status": "received", "message_id": str(message.id)}

return {
"status": "received",
"message_id": str(message.id),
"verified": verification.is_verified,
"spam_score": verification.spam_score,
}
Binary file added app/models/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added app/models/__pycache__/models.cpython-313.pyc
Binary file not shown.
32 changes: 29 additions & 3 deletions app/models/models.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import uuid
from datetime import datetime
from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean
from sqlalchemy import Column, String, DateTime, Text, ForeignKey, Boolean, Float, Integer
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from app.db.database import Base


Expand All @@ -11,7 +12,7 @@ def generate_api_key():

class Inbox(Base):
__tablename__ = "inboxes"

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)
Expand All @@ -21,7 +22,7 @@ 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)
sender = Column(String(255), nullable=False)
Expand All @@ -33,3 +34,28 @@ class Message(Base):
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

# Email verification fields
spf_pass = Column(Boolean, nullable=True)
dkim_pass = Column(Boolean, nullable=True)
spam_score = Column(Float, nullable=True)
is_verified = Column(Boolean, default=False)

# Relationship to attachments
attachments = relationship("Attachment", back_populates="message", lazy="joined")


class Attachment(Base):
__tablename__ = "attachments"

id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
message_id = Column(UUID(as_uuid=True), ForeignKey("messages.id"), index=True, nullable=False)
filename = Column(String(500), nullable=False)
content_type = Column(String(255), nullable=False)
size = Column(Integer, nullable=False)
s3_bucket = Column(String(255))
s3_key = Column(String(1024))
uploaded_at = Column(DateTime, default=datetime.utcnow)

# Relationship back to message
message = relationship("Message", back_populates="attachments")
Binary file added app/schemas/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file added app/schemas/__pycache__/schemas.cpython-313.pyc
Binary file not shown.
25 changes: 22 additions & 3 deletions app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class InboxResponse(BaseModel):
email_address: str
api_key: str
created_at: datetime

class Config:
from_attributes = True

Expand All @@ -23,7 +23,21 @@ class InboxPublic(BaseModel):
id: UUID
email_address: str
created_at: datetime


class Config:
from_attributes = True


# Attachment schemas
class AttachmentResponse(BaseModel):
id: UUID
filename: str
content_type: str
size: int
s3_bucket: Optional[str] = None
s3_key: Optional[str] = None
uploaded_at: datetime

class Config:
from_attributes = True

Expand All @@ -44,7 +58,12 @@ class MessageResponse(BaseModel):
body_text: Optional[str]
received_at: datetime
is_read: bool

spf_pass: Optional[bool] = None
dkim_pass: Optional[bool] = None
spam_score: Optional[float] = None
is_verified: Optional[bool] = None
attachments: List[AttachmentResponse] = []

class Config:
from_attributes = True

Expand Down
Binary file added app/services/__pycache__/__init__.cpython-313.pyc
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading