feat: import selected fork operational fixes
This commit is contained in:
@@ -10,6 +10,7 @@ from app.api.api_v1.endpoints import (
|
|||||||
settings,
|
settings,
|
||||||
setup,
|
setup,
|
||||||
stats,
|
stats,
|
||||||
|
webhook,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@@ -24,3 +25,4 @@ api_router.include_router(imap.router, prefix="/imap", tags=["imap"])
|
|||||||
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
api_router.include_router(stats.router, prefix="/stats", tags=["stats"])
|
||||||
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
|
api_router.include_router(mail_sources.router, prefix="/mail-sources", tags=["mail-sources"])
|
||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
|
api_router.include_router(webhook.router, prefix="/webhook", tags=["webhook"])
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ from typing import Any, Dict, Optional
|
|||||||
|
|
||||||
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
from app.core.database import SessionLocal, get_db
|
from app.core.database import SessionLocal
|
||||||
from app.core.security import require_admin_auth
|
from app.core.security import require_admin_auth
|
||||||
from app.services.imap_client import IMAPClient
|
from app.services.imap_client import IMAPClient
|
||||||
|
|
||||||
@@ -24,13 +24,14 @@ class IMAPTestRequest(BaseModel):
|
|||||||
ssl: bool = True
|
ssl: bool = True
|
||||||
|
|
||||||
|
|
||||||
def _fetch_imap_reports_background(days: int, delete_emails: bool) -> None:
|
def _fetch_imap_reports_sync(days: int, delete_emails: Optional[bool]) -> Dict[str, Any]:
|
||||||
"""Fetch IMAP reports with a standalone DB session for background imports."""
|
"""Fetch IMAP reports with a standalone DB session."""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
imap_client = IMAPClient(delete_emails=delete_emails, db=db)
|
imap_client = IMAPClient(delete_emails=delete_emails, db=db)
|
||||||
imap_client.fetch_reports(days=days)
|
results = imap_client.fetch_reports(days=days)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
return results
|
||||||
except Exception:
|
except Exception:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise
|
raise
|
||||||
@@ -38,6 +39,11 @@ def _fetch_imap_reports_background(days: int, delete_emails: bool) -> None:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_imap_reports_background(days: int, delete_emails: Optional[bool]) -> None:
|
||||||
|
"""Fetch IMAP reports from a FastAPI background task."""
|
||||||
|
_fetch_imap_reports_sync(days, delete_emails)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test-connection")
|
@router.post("/test-connection")
|
||||||
async def test_imap_connection(
|
async def test_imap_connection(
|
||||||
request: IMAPTestRequest,
|
request: IMAPTestRequest,
|
||||||
@@ -73,9 +79,8 @@ async def test_imap_connection(
|
|||||||
async def fetch_imap_reports(
|
async def fetch_imap_reports(
|
||||||
background_tasks: BackgroundTasks,
|
background_tasks: BackgroundTasks,
|
||||||
_auth: dict = Depends(require_admin_auth),
|
_auth: dict = Depends(require_admin_auth),
|
||||||
db: Session = Depends(get_db),
|
|
||||||
days: int = 7,
|
days: int = 7,
|
||||||
delete_emails: bool = False,
|
delete_emails: Optional[bool] = None,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""
|
"""
|
||||||
Fetch DMARC reports from the configured IMAP mailbox
|
Fetch DMARC reports from the configured IMAP mailbox
|
||||||
@@ -86,8 +91,6 @@ async def fetch_imap_reports(
|
|||||||
if days < 1 or days > 365:
|
if days < 1 or days > 365:
|
||||||
raise HTTPException(status_code=400, detail="Days parameter must be between 1 and 365")
|
raise HTTPException(status_code=400, detail="Days parameter must be between 1 and 365")
|
||||||
|
|
||||||
imap_client = IMAPClient(delete_emails=delete_emails, db=db)
|
|
||||||
|
|
||||||
# Run in background if it might take a while
|
# Run in background if it might take a while
|
||||||
if days > 14:
|
if days > 14:
|
||||||
background_tasks.add_task(_fetch_imap_reports_background, days, delete_emails)
|
background_tasks.add_task(_fetch_imap_reports_background, days, delete_emails)
|
||||||
@@ -99,8 +102,7 @@ async def fetch_imap_reports(
|
|||||||
|
|
||||||
# Otherwise run immediately
|
# Otherwise run immediately
|
||||||
try:
|
try:
|
||||||
results = imap_client.fetch_reports(days=days)
|
results = await run_in_threadpool(_fetch_imap_reports_sync, days, delete_emails)
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": results["success"],
|
"success": results["success"],
|
||||||
|
|||||||
@@ -287,7 +287,6 @@ def _fetch_imap_source(source: MailSource, db: Session, days: int) -> Dict[str,
|
|||||||
port=source.port or 993,
|
port=source.port or 993,
|
||||||
username=source.username,
|
username=source.username,
|
||||||
password=source.password,
|
password=source.password,
|
||||||
delete_emails=False,
|
|
||||||
folder=source.folder,
|
folder=source.folder,
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
"""Webhook ingestion endpoints for inbound DMARC report emails."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import email
|
||||||
|
import hmac
|
||||||
|
import logging
|
||||||
|
from email.header import decode_header
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.redaction import sanitize_for_log
|
||||||
|
from app.services.dmarc_parser import DMARCParser
|
||||||
|
from app.services.report_persistence import report_exists, save_parsed_report
|
||||||
|
from app.services.report_store import ReportStore
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class EmailWebhookPayload(BaseModel):
|
||||||
|
"""Payload for JSON webhook delivery from an email worker."""
|
||||||
|
|
||||||
|
raw_email: str
|
||||||
|
from_address: Optional[str] = None
|
||||||
|
to_address: Optional[str] = None
|
||||||
|
subject: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_email_header(header: Optional[str]) -> str:
|
||||||
|
"""Decode an RFC 2047 email header to display text."""
|
||||||
|
if not header:
|
||||||
|
return ""
|
||||||
|
decoded_parts = []
|
||||||
|
for text, encoding in decode_header(header):
|
||||||
|
if isinstance(text, bytes):
|
||||||
|
decoded_parts.append(text.decode(encoding or "utf-8", errors="replace"))
|
||||||
|
else:
|
||||||
|
decoded_parts.append(text)
|
||||||
|
return " ".join(decoded_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dmarc_filename(filename: str) -> bool:
|
||||||
|
lower = filename.lower()
|
||||||
|
return lower.endswith((".xml", ".zip", ".gz", ".gzip"))
|
||||||
|
|
||||||
|
|
||||||
|
def _require_webhook_secret(x_webhook_secret: Optional[str]) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.WEBHOOK_SECRET:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="Webhook ingestion is not configured.",
|
||||||
|
)
|
||||||
|
if not x_webhook_secret or not hmac.compare_digest(x_webhook_secret, settings.WEBHOOK_SECRET):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid webhook secret.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_report(db: Session, store: ReportStore, report: Dict[str, Any]) -> str:
|
||||||
|
domain = report.get("domain") or "unknown"
|
||||||
|
report_id = report.get("report_id") or ""
|
||||||
|
if report_id and (store.has_report(domain, report_id) or report_exists(db, domain, report_id)):
|
||||||
|
return "duplicate"
|
||||||
|
save_parsed_report(db, report)
|
||||||
|
store.add_report(report)
|
||||||
|
return "imported"
|
||||||
|
|
||||||
|
|
||||||
|
def _process_email_attachments(msg: email.message.Message, db: Session) -> Dict[str, Any]:
|
||||||
|
store = ReportStore.get_instance()
|
||||||
|
results: Dict[str, Any] = {
|
||||||
|
"reports_found": 0,
|
||||||
|
"imported": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"errors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
for part in msg.walk():
|
||||||
|
if part.get_content_disposition() != "attachment":
|
||||||
|
continue
|
||||||
|
|
||||||
|
filename = _decode_email_header(part.get_filename())
|
||||||
|
if not filename or not _is_dmarc_filename(filename):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = part.get_payload(decode=True)
|
||||||
|
if not content:
|
||||||
|
continue
|
||||||
|
report = DMARCParser.parse_file(content, filename)
|
||||||
|
outcome = _store_report(db, store, report)
|
||||||
|
results["reports_found"] += 1
|
||||||
|
if outcome == "duplicate":
|
||||||
|
results["duplicates"] += 1
|
||||||
|
else:
|
||||||
|
results["imported"] += 1
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
logger.warning(
|
||||||
|
"Webhook failed to process DMARC attachment %s: %s",
|
||||||
|
sanitize_for_log(filename),
|
||||||
|
sanitize_for_log(exc),
|
||||||
|
)
|
||||||
|
results["errors"].append(filename)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _subject_from_message(msg: email.message.Message, fallback: Optional[str] = None) -> str:
|
||||||
|
return fallback or _decode_email_header(msg.get("Subject"))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email")
|
||||||
|
async def receive_email(
|
||||||
|
payload: EmailWebhookPayload,
|
||||||
|
x_webhook_secret: Optional[str] = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Receive a base64 encoded raw email from an email worker webhook."""
|
||||||
|
_require_webhook_secret(x_webhook_secret)
|
||||||
|
try:
|
||||||
|
raw_email = base64.b64decode(payload.raw_email, validate=True)
|
||||||
|
except (ValueError, TypeError) as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="raw_email must be valid base64.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return _handle_raw_email(raw_email, db, subject=payload.subject)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/email/raw")
|
||||||
|
async def receive_raw_email(
|
||||||
|
request: Request,
|
||||||
|
x_webhook_secret: Optional[str] = Header(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Receive raw RFC 822 email bytes from an email worker webhook."""
|
||||||
|
_require_webhook_secret(x_webhook_secret)
|
||||||
|
return _handle_raw_email(await request.body(), db)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_raw_email(
|
||||||
|
raw_email: bytes,
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
subject: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
try:
|
||||||
|
msg = email.message_from_bytes(raw_email)
|
||||||
|
attachment_results = _process_email_attachments(msg, db)
|
||||||
|
db.commit()
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # pylint: disable=broad-exception-caught
|
||||||
|
db.rollback()
|
||||||
|
logger.warning("Webhook failed to process email: %s", sanitize_for_log(exc))
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Error processing email.",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"subject": _subject_from_message(msg, subject),
|
||||||
|
**attachment_results,
|
||||||
|
}
|
||||||
@@ -42,6 +42,7 @@ class Settings(BaseSettings):
|
|||||||
IMAP_USERNAME: Optional[str] = None
|
IMAP_USERNAME: Optional[str] = None
|
||||||
IMAP_PASSWORD: Optional[str] = None
|
IMAP_PASSWORD: Optional[str] = None
|
||||||
IMAP_FOLDER: str = "INBOX"
|
IMAP_FOLDER: str = "INBOX"
|
||||||
|
DELETE_IMPORTED_EMAILS: bool = False
|
||||||
|
|
||||||
# Admin User
|
# Admin User
|
||||||
FIRST_SUPERUSER: Optional[EmailStr] = None
|
FIRST_SUPERUSER: Optional[EmailStr] = None
|
||||||
@@ -50,6 +51,7 @@ class Settings(BaseSettings):
|
|||||||
# Optional Cloudflare Integration
|
# Optional Cloudflare Integration
|
||||||
CLOUDFLARE_API_TOKEN: Optional[str] = None
|
CLOUDFLARE_API_TOKEN: Optional[str] = None
|
||||||
CLOUDFLARE_ZONE_ID: Optional[str] = None
|
CLOUDFLARE_ZONE_ID: Optional[str] = None
|
||||||
|
WEBHOOK_SECRET: Optional[str] = None
|
||||||
|
|
||||||
# Admin API Key (optional)
|
# Admin API Key (optional)
|
||||||
# If set, this key is used directly instead of generating a random one at startup.
|
# If set, this key is used directly instead of generating a random one at startup.
|
||||||
|
|||||||
+34
-24
@@ -3,11 +3,12 @@ import logging
|
|||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import Depends, FastAPI, Request
|
from fastapi import Depends, FastAPI, Query, Request
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, HTMLResponse
|
from fastapi.responses import FileResponse, HTMLResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from starlette.concurrency import run_in_threadpool
|
||||||
|
|
||||||
import app.models.alert # noqa: F401 – ensure AlertHistory table is registered
|
import app.models.alert # noqa: F401 – ensure AlertHistory table is registered
|
||||||
import app.models.dns_cache # noqa: F401 – ensure DNSCache table is registered
|
import app.models.dns_cache # noqa: F401 – ensure DNSCache table is registered
|
||||||
@@ -54,7 +55,6 @@ def _poll_single_imap_source(source: MailSource) -> None:
|
|||||||
port=poll_source.port or 993,
|
port=poll_source.port or 993,
|
||||||
username=poll_source.username,
|
username=poll_source.username,
|
||||||
password=poll_source.password,
|
password=poll_source.password,
|
||||||
delete_emails=False,
|
|
||||||
folder=poll_source.folder,
|
folder=poll_source.folder,
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
@@ -547,7 +547,7 @@ async def health():
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _trigger_poll_imap_source(source: MailSource, db) -> dict:
|
def _trigger_poll_imap_source(source: MailSource, db, days: int = 7) -> dict:
|
||||||
"""Poll a single IMAP source and return a result dict for the API response."""
|
"""Poll a single IMAP source and return a result dict for the API response."""
|
||||||
global last_check_time # pylint: disable=global-statement
|
global last_check_time # pylint: disable=global-statement
|
||||||
|
|
||||||
@@ -556,12 +556,11 @@ def _trigger_poll_imap_source(source: MailSource, db) -> dict:
|
|||||||
port=source.port or 993,
|
port=source.port or 993,
|
||||||
username=source.username,
|
username=source.username,
|
||||||
password=source.password,
|
password=source.password,
|
||||||
delete_emails=False,
|
|
||||||
folder=source.folder,
|
folder=source.folder,
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
started_at = datetime.utcnow()
|
started_at = datetime.utcnow()
|
||||||
results = imap_client.fetch_reports(days=7)
|
results = imap_client.fetch_reports(days=days)
|
||||||
last_check_time = datetime.now()
|
last_check_time = datetime.now()
|
||||||
source.last_checked = datetime.utcnow()
|
source.last_checked = datetime.utcnow()
|
||||||
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
|
record_import_attempt(db, source, results, started_at=started_at, trigger="manual")
|
||||||
@@ -614,7 +613,7 @@ def _trigger_poll_gmail_source(source: MailSource, db) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _poll_source_for_trigger(source: MailSource, db) -> dict:
|
def _poll_source_for_trigger(source: MailSource, db, days: int = 7) -> dict:
|
||||||
"""Dispatch a single mail source for the manual trigger-poll endpoint.
|
"""Dispatch a single mail source for the manual trigger-poll endpoint.
|
||||||
|
|
||||||
Returns a result/summary dict that is included in the API response.
|
Returns a result/summary dict that is included in the API response.
|
||||||
@@ -639,7 +638,7 @@ def _poll_source_for_trigger(source: MailSource, db) -> dict:
|
|||||||
}
|
}
|
||||||
if source.method == "IMAP":
|
if source.method == "IMAP":
|
||||||
try:
|
try:
|
||||||
return _trigger_poll_imap_source(source, db)
|
return _trigger_poll_imap_source(source, db, days=days)
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
logger.error("Error polling mail source id=%d: %s", source.id, str(e))
|
||||||
return {
|
return {
|
||||||
@@ -656,14 +655,8 @@ def _poll_source_for_trigger(source: MailSource, db) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
# API endpoint to manually trigger IMAP polling
|
def _poll_enabled_sources_for_trigger(days: int) -> list[dict]:
|
||||||
@app.post("/api/v1/admin/trigger-poll")
|
"""Poll all enabled mail sources for the manual trigger-poll endpoint."""
|
||||||
async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)):
|
|
||||||
"""
|
|
||||||
Manually trigger IMAP polling for all enabled mail sources (admin only).
|
|
||||||
|
|
||||||
Security: Requires either X-API-Key header or Bearer token
|
|
||||||
"""
|
|
||||||
results_summary = []
|
results_summary = []
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
@@ -671,22 +664,39 @@ async def trigger_imap_poll(auth: dict = Depends(require_admin_auth)):
|
|||||||
db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712
|
db.query(MailSource).filter(MailSource.enabled == True).all() # noqa: E712
|
||||||
)
|
)
|
||||||
|
|
||||||
if not enabled_sources:
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": "No enabled mail sources configured.",
|
|
||||||
"sources_polled": 0,
|
|
||||||
"authenticated_by": auth.get("auth_type"),
|
|
||||||
}
|
|
||||||
|
|
||||||
for source in enabled_sources:
|
for source in enabled_sources:
|
||||||
results_summary.append(_poll_source_for_trigger(source, db))
|
results_summary.append(_poll_source_for_trigger(source, db, days=days))
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
return results_summary
|
||||||
|
|
||||||
|
|
||||||
|
# API endpoint to manually trigger IMAP polling
|
||||||
|
@app.post("/api/v1/admin/trigger-poll")
|
||||||
|
async def trigger_imap_poll(
|
||||||
|
auth: dict = Depends(require_admin_auth),
|
||||||
|
days: int = Query(7, ge=1, le=365, title="Number of days to fetch for IMAP sources"),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually trigger IMAP polling for all enabled mail sources (admin only).
|
||||||
|
|
||||||
|
Security: Requires either X-API-Key header or Bearer token
|
||||||
|
"""
|
||||||
|
results_summary = await run_in_threadpool(_poll_enabled_sources_for_trigger, days)
|
||||||
|
if not results_summary:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "No enabled mail sources configured.",
|
||||||
|
"sources_polled": 0,
|
||||||
|
"days": days,
|
||||||
|
"authenticated_by": auth.get("auth_type"),
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"success": all(r.get("success", True) for r in results_summary),
|
"success": all(r.get("success", True) for r in results_summary),
|
||||||
"timestamp": last_check_time.isoformat() if last_check_time else None,
|
"timestamp": last_check_time.isoformat() if last_check_time else None,
|
||||||
|
"days": days,
|
||||||
"sources": results_summary,
|
"sources": results_summary,
|
||||||
"authenticated_by": auth.get("auth_type"),
|
"authenticated_by": auth.get("auth_type"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import imaplib
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from email.header import decode_header
|
from email.header import decode_header
|
||||||
from typing import Any, Dict, Tuple
|
from typing import Any, Dict, Optional, Tuple
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.services.dmarc_parser import DMARCParser
|
from app.services.dmarc_parser import DMARCParser
|
||||||
@@ -25,7 +25,7 @@ class IMAPClient:
|
|||||||
port: int = None,
|
port: int = None,
|
||||||
username: str = None,
|
username: str = None,
|
||||||
password: str = None,
|
password: str = None,
|
||||||
delete_emails: bool = False,
|
delete_emails: Optional[bool] = None,
|
||||||
folder: str = None,
|
folder: str = None,
|
||||||
db: Any = None,
|
db: Any = None,
|
||||||
):
|
):
|
||||||
@@ -37,7 +37,8 @@ class IMAPClient:
|
|||||||
port: IMAP server port (if None, uses settings)
|
port: IMAP server port (if None, uses settings)
|
||||||
username: IMAP username (if None, uses settings)
|
username: IMAP username (if None, uses settings)
|
||||||
password: IMAP password (if None, uses settings)
|
password: IMAP password (if None, uses settings)
|
||||||
delete_emails: Whether to delete emails after processing (default: False)
|
delete_emails: Whether to delete emails after successful report imports.
|
||||||
|
If omitted, uses DELETE_IMPORTED_EMAILS from settings.
|
||||||
folder: IMAP mailbox folder to read (if None, uses settings or INBOX)
|
folder: IMAP mailbox folder to read (if None, uses settings or INBOX)
|
||||||
db: Optional SQLAlchemy session used to persist imported reports
|
db: Optional SQLAlchemy session used to persist imported reports
|
||||||
"""
|
"""
|
||||||
@@ -50,7 +51,10 @@ class IMAPClient:
|
|||||||
self.port = port or settings.IMAP_PORT
|
self.port = port or settings.IMAP_PORT
|
||||||
self.username = username or settings.IMAP_USERNAME
|
self.username = username or settings.IMAP_USERNAME
|
||||||
self.password = password or settings.IMAP_PASSWORD
|
self.password = password or settings.IMAP_PASSWORD
|
||||||
self.delete_emails = delete_emails
|
configured_delete = getattr(settings, "DELETE_IMPORTED_EMAILS", False)
|
||||||
|
if not isinstance(configured_delete, bool):
|
||||||
|
configured_delete = False
|
||||||
|
self.delete_emails = configured_delete if delete_emails is None else delete_emails
|
||||||
self.folder = folder or settings_folder or "INBOX"
|
self.folder = folder or settings_folder or "INBOX"
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
@@ -170,10 +174,11 @@ class IMAPClient:
|
|||||||
reports_found = self._process_attachments(msg, stats, message_id=message_id)
|
reports_found = self._process_attachments(msg, stats, message_id=message_id)
|
||||||
stats["reports_found"] += reports_found
|
stats["reports_found"] += reports_found
|
||||||
|
|
||||||
# Mark email as read (and optionally delete)
|
# Mark DMARC-looking email as read, and delete only after a successful import.
|
||||||
mail.store(email_id, "+FLAGS", "\\Seen")
|
mail.store(email_id, "+FLAGS", "\\Seen")
|
||||||
if self.delete_emails:
|
if self.delete_emails and reports_found > 0:
|
||||||
mail.store(email_id, "+FLAGS", "\\Deleted")
|
mail.store(email_id, "+FLAGS", "\\Deleted")
|
||||||
|
stats["deleted"] = stats.get("deleted", 0) + 1
|
||||||
|
|
||||||
stats["processed"] += 1
|
stats["processed"] += 1
|
||||||
except Exception as e: # pylint: disable=broad-exception-caught
|
except Exception as e: # pylint: disable=broad-exception-caught
|
||||||
@@ -206,6 +211,7 @@ class IMAPClient:
|
|||||||
"success": True,
|
"success": True,
|
||||||
"processed": 0,
|
"processed": 0,
|
||||||
"reports_found": 0,
|
"reports_found": 0,
|
||||||
|
"deleted": 0,
|
||||||
"duplicate_reports": 0,
|
"duplicate_reports": 0,
|
||||||
"new_domains": [],
|
"new_domains": [],
|
||||||
"errors": [],
|
"errors": [],
|
||||||
@@ -243,7 +249,7 @@ class IMAPClient:
|
|||||||
self._process_single_email(mail, email_id, stats)
|
self._process_single_email(mail, email_id, stats)
|
||||||
|
|
||||||
# Actually remove emails marked for deletion
|
# Actually remove emails marked for deletion
|
||||||
if self.delete_emails:
|
if self.delete_emails and stats["deleted"] > 0:
|
||||||
mail.expunge()
|
mail.expunge()
|
||||||
|
|
||||||
# Logout
|
# Logout
|
||||||
|
|||||||
@@ -188,6 +188,20 @@ class TestLogtoSettings:
|
|||||||
assert settings.LOGTO_SKIP_SSL_VERIFY is True
|
assert settings.LOGTO_SKIP_SSL_VERIFY is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestImapSettings:
|
||||||
|
def test_delete_imported_emails_defaults_false(self, monkeypatch):
|
||||||
|
monkeypatch.delenv("DELETE_IMPORTED_EMAILS", raising=False)
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.DELETE_IMPORTED_EMAILS is False
|
||||||
|
|
||||||
|
def test_delete_imported_emails_reads_env(self, monkeypatch):
|
||||||
|
monkeypatch.setenv("DELETE_IMPORTED_EMAILS", "true")
|
||||||
|
settings = Settings()
|
||||||
|
|
||||||
|
assert settings.DELETE_IMPORTED_EMAILS is True
|
||||||
|
|
||||||
|
|
||||||
class TestProductionStartupSettings:
|
class TestProductionStartupSettings:
|
||||||
"""Tests for production-critical settings and startup validation."""
|
"""Tests for production-critical settings and startup validation."""
|
||||||
|
|
||||||
|
|||||||
@@ -139,6 +139,19 @@ class TestIMAPClientInit:
|
|||||||
assert client.password == "secret"
|
assert client.password == "secret"
|
||||||
assert client.delete_emails is True
|
assert client.delete_emails is True
|
||||||
|
|
||||||
|
def test_delete_emails_defaults_to_settings(self):
|
||||||
|
settings = SimpleNamespace(
|
||||||
|
IMAP_SERVER="imap.example.com",
|
||||||
|
IMAP_PORT=993,
|
||||||
|
IMAP_USERNAME="u",
|
||||||
|
IMAP_PASSWORD="p",
|
||||||
|
DELETE_IMPORTED_EMAILS=True,
|
||||||
|
)
|
||||||
|
with patch("app.services.imap_client.get_settings", return_value=settings):
|
||||||
|
client = IMAPClient()
|
||||||
|
|
||||||
|
assert client.delete_emails is True
|
||||||
|
|
||||||
def test_folder_uses_explicit_value_or_settings_default(self):
|
def test_folder_uses_explicit_value_or_settings_default(self):
|
||||||
"""Folder defaults to settings and can be overridden explicitly."""
|
"""Folder defaults to settings and can be overridden explicitly."""
|
||||||
settings = SimpleNamespace(
|
settings = SimpleNamespace(
|
||||||
@@ -223,6 +236,7 @@ class TestTestConnection:
|
|||||||
IMAP_PORT=993,
|
IMAP_PORT=993,
|
||||||
IMAP_USERNAME=username,
|
IMAP_USERNAME=username,
|
||||||
IMAP_PASSWORD=password,
|
IMAP_PASSWORD=password,
|
||||||
|
DELETE_IMPORTED_EMAILS=False,
|
||||||
)
|
)
|
||||||
return IMAPClient()
|
return IMAPClient()
|
||||||
|
|
||||||
@@ -233,6 +247,7 @@ class TestTestConnection:
|
|||||||
IMAP_PORT=993,
|
IMAP_PORT=993,
|
||||||
IMAP_USERNAME=None,
|
IMAP_USERNAME=None,
|
||||||
IMAP_PASSWORD=None,
|
IMAP_PASSWORD=None,
|
||||||
|
DELETE_IMPORTED_EMAILS=False,
|
||||||
)
|
)
|
||||||
client = IMAPClient()
|
client = IMAPClient()
|
||||||
success, message, stats = client.test_connection()
|
success, message, stats = client.test_connection()
|
||||||
@@ -634,11 +649,31 @@ class TestProcessSingleEmail:
|
|||||||
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
|
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
|
||||||
mock_mail.store.return_value = ("OK", None)
|
mock_mail.store.return_value = ("OK", None)
|
||||||
|
|
||||||
stats = {"processed": 0, "reports_found": 0, "errors": []}
|
stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []}
|
||||||
client._process_single_email(mock_mail, b"1", stats)
|
client._process_single_email(mock_mail, b"1", stats)
|
||||||
|
|
||||||
# store should have been called twice: once for \\Seen, once for \\Deleted
|
# store should have been called twice: once for \\Seen, once for \\Deleted
|
||||||
assert mock_mail.store.call_count >= 2
|
assert mock_mail.store.call_count >= 2
|
||||||
|
assert stats["deleted"] == 1
|
||||||
|
|
||||||
|
def test_does_not_delete_when_no_report_imported(self):
|
||||||
|
client = self._make_client()
|
||||||
|
client.delete_emails = True
|
||||||
|
raw = _make_email_with_attachment(
|
||||||
|
"not-a-report.txt",
|
||||||
|
b"not a report",
|
||||||
|
"text/plain",
|
||||||
|
subject="DMARC Report",
|
||||||
|
)
|
||||||
|
mock_mail = MagicMock()
|
||||||
|
mock_mail.fetch.return_value = ("OK", [(b"1", raw)])
|
||||||
|
mock_mail.store.return_value = ("OK", None)
|
||||||
|
|
||||||
|
stats = {"processed": 0, "reports_found": 0, "deleted": 0, "errors": []}
|
||||||
|
client._process_single_email(mock_mail, b"1", stats)
|
||||||
|
|
||||||
|
mock_mail.store.assert_called_once_with(b"1", "+FLAGS", "\\Seen")
|
||||||
|
assert stats["deleted"] == 0
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -736,7 +771,22 @@ class TestFetchReports:
|
|||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
mock_mail.login.return_value = None
|
mock_mail.login.return_value = None
|
||||||
mock_mail.select.return_value = ("OK", [b"0"])
|
mock_mail.select.return_value = ("OK", [b"0"])
|
||||||
mock_mail.search.return_value = ("OK", [b""])
|
mock_mail.search.return_value = ("OK", [b"1"])
|
||||||
|
mock_mail.fetch.return_value = (
|
||||||
|
"OK",
|
||||||
|
[
|
||||||
|
(
|
||||||
|
b"1",
|
||||||
|
_make_email_with_attachment(
|
||||||
|
"report.xml",
|
||||||
|
MINIMAL_DMARC_XML,
|
||||||
|
"application/xml",
|
||||||
|
subject="DMARC Report",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
mock_mail.store.return_value = ("OK", None)
|
||||||
mock_mail.logout.return_value = None
|
mock_mail.logout.return_value = None
|
||||||
|
|
||||||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||||
@@ -744,3 +794,20 @@ class TestFetchReports:
|
|||||||
|
|
||||||
mock_mail.expunge.assert_called_once()
|
mock_mail.expunge.assert_called_once()
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
|
assert result["deleted"] == 1
|
||||||
|
|
||||||
|
def test_delete_emails_skips_expunge_when_nothing_deleted(self):
|
||||||
|
client = self._make_client()
|
||||||
|
client.delete_emails = True
|
||||||
|
mock_mail = MagicMock()
|
||||||
|
mock_mail.login.return_value = None
|
||||||
|
mock_mail.select.return_value = ("OK", [b"0"])
|
||||||
|
mock_mail.search.return_value = ("OK", [b""])
|
||||||
|
mock_mail.logout.return_value = None
|
||||||
|
|
||||||
|
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||||
|
result = client.fetch_reports(days=3)
|
||||||
|
|
||||||
|
mock_mail.expunge.assert_not_called()
|
||||||
|
assert result["success"] is True
|
||||||
|
assert result["deleted"] == 0
|
||||||
|
|||||||
@@ -83,7 +83,10 @@ class TestImapFetchReports:
|
|||||||
"errors": [],
|
"errors": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
with (
|
||||||
|
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||||
|
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||||
|
):
|
||||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=7")
|
response = authed_client.post("/api/v1/imap/fetch-reports?days=7")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -103,7 +106,10 @@ class TestImapFetchReports:
|
|||||||
def test_fetch_background_for_long_range(self, authed_client: TestClient):
|
def test_fetch_background_for_long_range(self, authed_client: TestClient):
|
||||||
"""Days > 14 should queue a background task."""
|
"""Days > 14 should queue a background task."""
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
with (
|
||||||
|
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||||
|
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||||
|
):
|
||||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=30")
|
response = authed_client.post("/api/v1/imap/fetch-reports?days=30")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -121,7 +127,10 @@ class TestImapFetchReports:
|
|||||||
"errors": ["Could not parse email 1"],
|
"errors": ["Could not parse email 1"],
|
||||||
}
|
}
|
||||||
|
|
||||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
with (
|
||||||
|
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||||
|
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||||
|
):
|
||||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=3")
|
response = authed_client.post("/api/v1/imap/fetch-reports?days=3")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
@@ -132,7 +141,10 @@ class TestImapFetchReports:
|
|||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
mock_client.fetch_reports.side_effect = RuntimeError("unexpected")
|
mock_client.fetch_reports.side_effect = RuntimeError("unexpected")
|
||||||
|
|
||||||
with patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client):
|
with (
|
||||||
|
patch("app.api.api_v1.endpoints.imap.IMAPClient", return_value=mock_client),
|
||||||
|
patch("app.api.api_v1.endpoints.imap.SessionLocal", return_value=MagicMock()),
|
||||||
|
):
|
||||||
response = authed_client.post("/api/v1/imap/fetch-reports?days=5")
|
response = authed_client.post("/api/v1/imap/fetch-reports?days=5")
|
||||||
|
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
|
|||||||
@@ -875,9 +875,7 @@ class TestManualSourceFetchEndpoint:
|
|||||||
assert data["error_count"] == 1
|
assert data["error_count"] == 1
|
||||||
assert "bad attachment with newline" in caplog.text
|
assert "bad attachment with newline" in caplog.text
|
||||||
history_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/imports")
|
history_resp = authed_client.get(f"/api/v1/mail-sources/{source_id}/imports")
|
||||||
assert history_resp.json()[0]["details"] == [
|
assert history_resp.json()[0]["details"] == [{"status": "error", "filename": "bad.xml"}]
|
||||||
{"status": "error", "filename": "bad.xml"}
|
|
||||||
]
|
|
||||||
mock_imap.fetch_reports.assert_called_once_with(days=30)
|
mock_imap.fetch_reports.assert_called_once_with(days=30)
|
||||||
|
|
||||||
def test_fetch_gmail_source(self, authed_client: TestClient, db_session: Session):
|
def test_fetch_gmail_source(self, authed_client: TestClient, db_session: Session):
|
||||||
@@ -1806,8 +1804,33 @@ class TestTriggerPollImapSource:
|
|||||||
assert result["processed"] == 3
|
assert result["processed"] == 3
|
||||||
assert result["reports_found"] == 2
|
assert result["reports_found"] == 2
|
||||||
assert result["new_domains"] == ["dom.example"]
|
assert result["new_domains"] == ["dom.example"]
|
||||||
|
mock_imap.fetch_reports.assert_called_once_with(days=7)
|
||||||
mock_db.commit.assert_called_once()
|
mock_db.commit.assert_called_once()
|
||||||
|
|
||||||
|
def test_uses_requested_days(self):
|
||||||
|
from app.main import _trigger_poll_imap_source
|
||||||
|
|
||||||
|
src = MagicMock()
|
||||||
|
src.id = 5
|
||||||
|
src.name = "My IMAP"
|
||||||
|
src.server = "imap.example.com"
|
||||||
|
src.port = 993
|
||||||
|
src.username = "u"
|
||||||
|
src.password = "p"
|
||||||
|
|
||||||
|
mock_imap = MagicMock()
|
||||||
|
mock_imap.fetch_reports.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"processed": 0,
|
||||||
|
"reports_found": 0,
|
||||||
|
"new_domains": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch("app.main.IMAPClient", return_value=mock_imap):
|
||||||
|
_trigger_poll_imap_source(src, MagicMock(), days=30)
|
||||||
|
|
||||||
|
mock_imap.fetch_reports.assert_called_once_with(days=30)
|
||||||
|
|
||||||
|
|
||||||
class TestTriggerPollGmailSource:
|
class TestTriggerPollGmailSource:
|
||||||
"""Unit tests for app.main._trigger_poll_gmail_source."""
|
"""Unit tests for app.main._trigger_poll_gmail_source."""
|
||||||
@@ -1938,7 +1961,7 @@ class TestPollSourceForTrigger:
|
|||||||
result = _poll_source_for_trigger(src, MagicMock())
|
result = _poll_source_for_trigger(src, MagicMock())
|
||||||
|
|
||||||
assert result is expected
|
assert result is expected
|
||||||
mock_fn.assert_called_once()
|
assert mock_fn.call_args.kwargs["days"] == 7
|
||||||
|
|
||||||
def test_imap_exception_returns_failure_dict(self):
|
def test_imap_exception_returns_failure_dict(self):
|
||||||
from app.main import _poll_source_for_trigger
|
from app.main import _poll_source_for_trigger
|
||||||
@@ -2092,15 +2115,20 @@ class TestTriggerPollEndpoint:
|
|||||||
with TestClient(main_app) as tc:
|
with TestClient(main_app) as tc:
|
||||||
with (
|
with (
|
||||||
patch("app.main.SessionLocal", return_value=mock_db),
|
patch("app.main.SessionLocal", return_value=mock_db),
|
||||||
patch("app.main._poll_source_for_trigger", return_value=mock_result),
|
patch(
|
||||||
|
"app.main._poll_source_for_trigger", return_value=mock_result
|
||||||
|
) as mock_poll,
|
||||||
):
|
):
|
||||||
resp = tc.post("/api/v1/admin/trigger-poll")
|
resp = tc.post("/api/v1/admin/trigger-poll?days=30")
|
||||||
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
data = resp.json()
|
data = resp.json()
|
||||||
|
assert data["days"] == 30
|
||||||
assert "sources" in data
|
assert "sources" in data
|
||||||
assert len(data["sources"]) == 1
|
assert len(data["sources"]) == 1
|
||||||
assert data["sources"][0]["success"] is True
|
assert data["sources"][0]["success"] is True
|
||||||
|
assert mock_result == data["sources"][0]
|
||||||
|
assert mock_poll.call_args.kwargs["days"] == 30
|
||||||
finally:
|
finally:
|
||||||
main_app.dependency_overrides.clear()
|
main_app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,6 @@ def test_poll_single_imap_source_passes_configured_folder():
|
|||||||
port=993,
|
port=993,
|
||||||
username="u",
|
username="u",
|
||||||
password="p",
|
password="p",
|
||||||
delete_emails=False,
|
|
||||||
folder="Junk Mail",
|
folder="Junk Mail",
|
||||||
db=db,
|
db=db,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import base64
|
||||||
|
from email.mime.application import MIMEApplication
|
||||||
|
from email.mime.multipart import MIMEMultipart
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.models.report import DMARCReport
|
||||||
|
|
||||||
|
MINIMAL_DMARC_XML = b"""\
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<feedback>
|
||||||
|
<report_metadata>
|
||||||
|
<org_name>Webhook Test</org_name>
|
||||||
|
<email>dmarc@example.com</email>
|
||||||
|
<report_id>webhook-001</report_id>
|
||||||
|
<date_range>
|
||||||
|
<begin>1609459200</begin>
|
||||||
|
<end>1609545600</end>
|
||||||
|
</date_range>
|
||||||
|
</report_metadata>
|
||||||
|
<policy_published>
|
||||||
|
<domain>webhook.example</domain>
|
||||||
|
<adkim>r</adkim>
|
||||||
|
<aspf>r</aspf>
|
||||||
|
<p>none</p>
|
||||||
|
<sp>none</sp>
|
||||||
|
<pct>100</pct>
|
||||||
|
</policy_published>
|
||||||
|
<record>
|
||||||
|
<row>
|
||||||
|
<source_ip>1.2.3.4</source_ip>
|
||||||
|
<count>1</count>
|
||||||
|
<policy_evaluated>
|
||||||
|
<disposition>none</disposition>
|
||||||
|
<dkim>pass</dkim>
|
||||||
|
<spf>pass</spf>
|
||||||
|
</policy_evaluated>
|
||||||
|
</row>
|
||||||
|
<identifiers>
|
||||||
|
<header_from>webhook.example</header_from>
|
||||||
|
</identifiers>
|
||||||
|
</record>
|
||||||
|
</feedback>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _raw_email_with_report() -> bytes:
|
||||||
|
msg = MIMEMultipart()
|
||||||
|
msg["Subject"] = "DMARC report"
|
||||||
|
part = MIMEApplication(MINIMAL_DMARC_XML, _subtype="xml")
|
||||||
|
part.add_header("Content-Disposition", "attachment", filename="report.xml")
|
||||||
|
msg.attach(part)
|
||||||
|
return msg.as_bytes()
|
||||||
|
|
||||||
|
|
||||||
|
def _set_webhook_secret(monkeypatch, value="test-webhook-secret"):
|
||||||
|
monkeypatch.setenv("WEBHOOK_SECRET", value)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_settings_cache():
|
||||||
|
yield
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_requires_configured_secret(client: TestClient, monkeypatch):
|
||||||
|
monkeypatch.delenv("WEBHOOK_SECRET", raising=False)
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/webhook/email",
|
||||||
|
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_rejects_invalid_secret(client: TestClient, monkeypatch):
|
||||||
|
_set_webhook_secret(monkeypatch)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/webhook/email",
|
||||||
|
headers={"X-Webhook-Secret": "wrong"},
|
||||||
|
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_imports_base64_email(client: TestClient, db_session, monkeypatch):
|
||||||
|
secret = _set_webhook_secret(monkeypatch)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/webhook/email",
|
||||||
|
headers={"X-Webhook-Secret": secret},
|
||||||
|
json={"raw_email": base64.b64encode(_raw_email_with_report()).decode("ascii")},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["success"] is True
|
||||||
|
assert data["reports_found"] == 1
|
||||||
|
assert data["imported"] == 1
|
||||||
|
assert db_session.query(DMARCReport).count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_webhook_raw_email_marks_duplicate(client: TestClient, monkeypatch):
|
||||||
|
secret = _set_webhook_secret(monkeypatch)
|
||||||
|
raw_email = _raw_email_with_report()
|
||||||
|
|
||||||
|
first = client.post(
|
||||||
|
"/api/v1/webhook/email/raw",
|
||||||
|
headers={"X-Webhook-Secret": secret},
|
||||||
|
content=raw_email,
|
||||||
|
)
|
||||||
|
second = client.post(
|
||||||
|
"/api/v1/webhook/email/raw",
|
||||||
|
headers={"X-Webhook-Secret": secret},
|
||||||
|
content=raw_email,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert first.status_code == 200
|
||||||
|
assert second.status_code == 200
|
||||||
|
assert second.json()["duplicates"] == 1
|
||||||
+2
-3
@@ -26,22 +26,21 @@ services:
|
|||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app # development: mount source code for live reload
|
|
||||||
- app_data:/app/data # persist SQLite database and other application data
|
- app_data:/app/data # persist SQLite database and other application data
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
environment:
|
environment:
|
||||||
|
- SERVICE_FQDN_APP_8080
|
||||||
- DATABASE_URL=postgresql://dmarq_user:dmarq_secure_password@db:5432/dmarq_db
|
- DATABASE_URL=postgresql://dmarq_user:dmarq_secure_password@db:5432/dmarq_db
|
||||||
- SECRET_KEY=your_secret_key_change_in_production
|
- SECRET_KEY=your_secret_key_change_in_production
|
||||||
- DEBUG=True
|
- DEBUG=True
|
||||||
- ENVIRONMENT=development
|
- ENVIRONMENT=development
|
||||||
|
- DELETE_IMPORTED_EMAILS=false
|
||||||
# Add NODE_ENV for Tailwind
|
# Add NODE_ENV for Tailwind
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
networks:
|
networks:
|
||||||
- dmarq-network
|
- dmarq-network
|
||||||
ports:
|
|
||||||
- "80:8080" # Map directly to port 80 for web access
|
|
||||||
|
|
||||||
# Define networks
|
# Define networks
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ For database operations, use the [Database Backup and Restore](backups.md) guide
|
|||||||
| `IMAP_FOLDER` | IMAP folder to check | `INBOX` | `DMARC`, `reports` |
|
| `IMAP_FOLDER` | IMAP folder to check | `INBOX` | `DMARC`, `reports` |
|
||||||
| `IMAP_MARK_AS_READ` | Mark processed emails as read | `true` | `true`, `false` |
|
| `IMAP_MARK_AS_READ` | Mark processed emails as read | `true` | `true`, `false` |
|
||||||
| `IMAP_ARCHIVE_FOLDER` | Folder to move processed emails to | - | `Processed`, `Archive` |
|
| `IMAP_ARCHIVE_FOLDER` | Folder to move processed emails to | - | `Processed`, `Archive` |
|
||||||
|
| `DELETE_IMPORTED_EMAILS` | Delete IMAP emails after a DMARC report is successfully imported | `false` | `true`, `false` |
|
||||||
|
|
||||||
### Application Settings
|
### Application Settings
|
||||||
|
|
||||||
@@ -108,6 +109,7 @@ policy if long-term storage size matters.
|
|||||||
| `CF_ENABLED` | Enable Cloudflare integration | `false` | `true`, `false` |
|
| `CF_ENABLED` | Enable Cloudflare integration | `false` | `true`, `false` |
|
||||||
| `CF_API_TOKEN` | Cloudflare API token | - | `your_cloudflare_api_token` |
|
| `CF_API_TOKEN` | Cloudflare API token | - | `your_cloudflare_api_token` |
|
||||||
| `CF_ZONE_ID` | Cloudflare Zone ID | - | `your_cloudflare_zone_id` |
|
| `CF_ZONE_ID` | Cloudflare Zone ID | - | `your_cloudflare_zone_id` |
|
||||||
|
| `WEBHOOK_SECRET` | Required secret for inbound email worker webhooks | - | `openssl rand -hex 32` |
|
||||||
|
|
||||||
### DNS Result Cache
|
### DNS Result Cache
|
||||||
|
|
||||||
|
|||||||
@@ -45,9 +45,11 @@ The fastest way to get DMARQ running is to use Docker Compose:
|
|||||||
# IMAP_PASSWORD=your_secure_password
|
# IMAP_PASSWORD=your_secure_password
|
||||||
# IMAP_USE_SSL=true
|
# IMAP_USE_SSL=true
|
||||||
# IMAP_POLLING_INTERVAL=60
|
# IMAP_POLLING_INTERVAL=60
|
||||||
|
# DELETE_IMPORTED_EMAILS=false
|
||||||
|
|
||||||
# Security Settings
|
# Security Settings
|
||||||
SECRET_KEY=generate_a_secure_random_key
|
SECRET_KEY=generate_a_secure_random_key
|
||||||
|
# WEBHOOK_SECRET=generate_a_separate_webhook_secret
|
||||||
ALLOWED_HOSTS=localhost,127.0.0.1
|
ALLOWED_HOSTS=localhost,127.0.0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -104,7 +106,9 @@ services:
|
|||||||
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
|
- IMAP_PASSWORD=${IMAP_PASSWORD:-}
|
||||||
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
|
- IMAP_USE_SSL=${IMAP_USE_SSL:-true}
|
||||||
- IMAP_POLLING_INTERVAL=${IMAP_POLLING_INTERVAL:-60}
|
- IMAP_POLLING_INTERVAL=${IMAP_POLLING_INTERVAL:-60}
|
||||||
|
- DELETE_IMPORTED_EMAILS=${DELETE_IMPORTED_EMAILS:-false}
|
||||||
- SECRET_KEY=${SECRET_KEY:-insecure_key_change_me_in_production}
|
- SECRET_KEY=${SECRET_KEY:-insecure_key_change_me_in_production}
|
||||||
|
- WEBHOOK_SECRET=${WEBHOOK_SECRET:-}
|
||||||
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-localhost,127.0.0.1}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
|
|||||||
Reference in New Issue
Block a user