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
3 changes: 2 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
[email protected]
S3_BUCKET=your_attachment_bucket
10 changes: 4 additions & 6 deletions app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
131 changes: 98 additions & 33 deletions app/main.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"}
Expand All @@ -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


Expand All @@ -75,15 +122,15 @@ def send_email(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="AWS SES not configured"
)

try:
ses_client = boto3.client(
'ses',
aws_access_key_id=settings.aws_access_key_id,
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]},
Expand All @@ -95,8 +142,7 @@ def send_email(
}
}
)

# Store sent message

sent_msg = models.Message(
inbox_id=inbox.id,
sender=inbox.email_address,
Expand All @@ -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,
Expand All @@ -131,50 +177,69 @@ 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,
recipient=recipient,
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,
}
21 changes: 12 additions & 9 deletions app/models/models.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand All @@ -21,15 +20,19 @@ 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))
body_text = Column(Text)
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)
14 changes: 8 additions & 6 deletions app/schemas/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,16 @@
from pydantic import BaseModel, EmailStr


# Inbox schemas
class InboxCreate(BaseModel):
pass # No fields needed - we generate everything
pass


class InboxResponse(BaseModel):
id: UUID
email_address: str
api_key: str
created_at: datetime

class Config:
from_attributes = True

Expand All @@ -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
Expand All @@ -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

Expand Down
2 changes: 2 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import os

os.environ.setdefault("DATABASE_URL", "sqlite:///./test.db")
Loading