feat: allow participants to ask for email transcript (#923)

* feat: allow participants to ask for email transcript

* fix: set email update in a transaction
This commit is contained in:
Juan Diego García
2026-03-20 15:43:58 -05:00
committed by GitHub
parent 41e7b3e84f
commit 55222ecc47
27 changed files with 803 additions and 10 deletions

View File

@@ -0,0 +1,29 @@
"""add email_recipients to meeting
Revision ID: a2b3c4d5e6f7
Revises: 501c73a6b0d5
Create Date: 2026-03-20 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import JSONB
revision: str = "a2b3c4d5e6f7"
down_revision: Union[str, None] = "501c73a6b0d5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column(
"meeting",
sa.Column("email_recipients", JSONB, nullable=True),
)
def downgrade() -> None:
op.drop_column("meeting", "email_recipients")

View File

@@ -40,6 +40,8 @@ dependencies = [
"icalendar>=6.0.0",
"hatchet-sdk==1.22.16",
"pydantic>=2.12.5",
"aiosmtplib>=3.0.0",
"email-validator>=2.0.0",
]
[dependency-groups]

View File

@@ -1,3 +1,4 @@
from contextlib import asynccontextmanager
from datetime import datetime, timedelta
from typing import Any, Literal
@@ -66,6 +67,8 @@ meetings = sa.Table(
# Daily.co composed video (Brady Bunch grid layout) - Daily.co only, not Whereby
sa.Column("daily_composed_video_s3_key", sa.String, nullable=True),
sa.Column("daily_composed_video_duration", sa.Integer, nullable=True),
# Email recipients for transcript notification
sa.Column("email_recipients", JSONB, nullable=True),
sa.Index("idx_meeting_room_id", "room_id"),
sa.Index("idx_meeting_calendar_event", "calendar_event_id"),
)
@@ -116,6 +119,8 @@ class Meeting(BaseModel):
# Daily.co composed video (Brady Bunch grid) - Daily.co only
daily_composed_video_s3_key: str | None = None
daily_composed_video_duration: int | None = None
# Email recipients for transcript notification
email_recipients: list[str] | None = None
class MeetingController:
@@ -388,6 +393,24 @@ class MeetingController:
# If was_null=False, the WHERE clause prevented the update
return was_null
@asynccontextmanager
async def transaction(self):
"""A context manager for database transaction."""
async with get_database().transaction(isolation="serializable"):
yield
async def add_email_recipient(self, meeting_id: str, email: str) -> list[str]:
"""Add an email to the meeting's email_recipients list (no duplicates)."""
async with self.transaction():
meeting = await self.get_by_id(meeting_id)
if not meeting:
raise ValueError(f"Meeting {meeting_id} not found")
current = meeting.email_recipients or []
if email not in current:
current.append(email)
await self.update_meeting(meeting_id, email_recipients=current)
return current
async def increment_num_clients(self, meeting_id: str) -> None:
"""Atomically increment participant count."""
query = (

84
server/reflector/email.py Normal file
View File

@@ -0,0 +1,84 @@
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
import structlog
from reflector.db.transcripts import Transcript
from reflector.settings import settings
logger = structlog.get_logger(__name__)
def is_email_configured() -> bool:
return bool(settings.SMTP_HOST and settings.SMTP_FROM_EMAIL)
def get_transcript_url(transcript: Transcript) -> str:
return f"{settings.UI_BASE_URL}/transcripts/{transcript.id}"
def _build_plain_text(transcript: Transcript, url: str) -> str:
title = transcript.title or "Unnamed recording"
lines = [
f"Your transcript is ready: {title}",
"",
f"View it here: {url}",
]
if transcript.short_summary:
lines.extend(["", "Summary:", transcript.short_summary])
return "\n".join(lines)
def _build_html(transcript: Transcript, url: str) -> str:
title = transcript.title or "Unnamed recording"
summary_html = ""
if transcript.short_summary:
summary_html = f"<p style='color:#555;'>{transcript.short_summary}</p>"
return f"""\
<div style="font-family:sans-serif;max-width:600px;margin:0 auto;">
<h2>Your transcript is ready</h2>
<p><strong>{title}</strong></p>
{summary_html}
<p><a href="{url}" style="display:inline-block;padding:10px 20px;background:#4A90D9;color:#fff;text-decoration:none;border-radius:4px;">View Transcript</a></p>
<p style="color:#999;font-size:12px;">This email was sent because you requested to receive the transcript from a meeting.</p>
</div>"""
async def send_transcript_email(to_emails: list[str], transcript: Transcript) -> int:
"""Send transcript notification to all emails. Returns count sent."""
if not is_email_configured() or not to_emails:
return 0
url = get_transcript_url(transcript)
title = transcript.title or "Unnamed recording"
sent = 0
for email_addr in to_emails:
msg = MIMEMultipart("alternative")
msg["Subject"] = f"Transcript Ready: {title}"
msg["From"] = settings.SMTP_FROM_EMAIL
msg["To"] = email_addr
msg.attach(MIMEText(_build_plain_text(transcript, url), "plain"))
msg.attach(MIMEText(_build_html(transcript, url), "html"))
try:
await aiosmtplib.send(
msg,
hostname=settings.SMTP_HOST,
port=settings.SMTP_PORT,
username=settings.SMTP_USERNAME,
password=settings.SMTP_PASSWORD,
start_tls=settings.SMTP_USE_TLS,
)
sent += 1
except Exception:
logger.exception(
"Failed to send transcript email",
to=email_addr,
transcript_id=transcript.id,
)
return sent

View File

@@ -21,6 +21,7 @@ class TaskName(StrEnum):
CLEANUP_CONSENT = "cleanup_consent"
POST_ZULIP = "post_zulip"
SEND_WEBHOOK = "send_webhook"
SEND_EMAIL = "send_email"
PAD_TRACK = "pad_track"
TRANSCRIBE_TRACK = "transcribe_track"
DETECT_CHUNK_TOPIC = "detect_chunk_topic"
@@ -59,7 +60,7 @@ TIMEOUT_AUDIO = 720 # Audio processing: padding, mixdown (Hatchet execution_tim
TIMEOUT_AUDIO_HTTP = (
660 # httpx timeout for pad_track — below 720 so Hatchet doesn't race
)
TIMEOUT_HEAVY = 600 # Transcription, fan-out LLM tasks (Hatchet execution_timeout)
TIMEOUT_HEAVY = 1200 # Transcription, fan-out LLM tasks (Hatchet execution_timeout)
TIMEOUT_HEAVY_HTTP = (
540 # httpx timeout for transcribe_track — below 600 so Hatchet doesn't race
1150 # httpx timeout for transcribe_track — below 1200 so Hatchet doesn't race
)

View File

@@ -33,6 +33,7 @@ from hatchet_sdk.labels import DesiredWorkerLabel
from pydantic import BaseModel
from reflector.dailyco_api.client import DailyApiClient
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.broadcast import (
append_event_and_broadcast,
set_status_and_broadcast,
@@ -51,6 +52,7 @@ from reflector.hatchet.error_classification import is_non_retryable
from reflector.hatchet.workflows.models import (
ActionItemsResult,
ConsentResult,
EmailResult,
FinalizeResult,
MixdownResult,
PaddedTrackInfo,
@@ -1465,6 +1467,52 @@ async def send_webhook(input: PipelineInput, ctx: Context) -> WebhookResult:
return WebhookResult(webhook_sent=False)
@daily_multitrack_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: PipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
async def on_workflow_failure(input: PipelineInput, ctx: Context) -> None:
"""Run when the workflow is truly dead (all retries exhausted).

View File

@@ -18,6 +18,7 @@ from pathlib import Path
from hatchet_sdk import Context
from pydantic import BaseModel
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.broadcast import (
append_event_and_broadcast,
set_status_and_broadcast,
@@ -37,6 +38,7 @@ from reflector.hatchet.workflows.daily_multitrack_pipeline import (
)
from reflector.hatchet.workflows.models import (
ConsentResult,
EmailResult,
TitleResult,
TopicsResult,
WaveformResult,
@@ -859,6 +861,54 @@ async def send_webhook(input: FilePipelineInput, ctx: Context) -> WebhookResult:
return WebhookResult(webhook_sent=False)
@file_pipeline.task(
parents=[cleanup_consent],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: FilePipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
# Try transcript.meeting_id first, then fall back to recording.meeting_id
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
# Set transcript to public so the link works for anyone
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
# --- On failure handler ---

View File

@@ -17,6 +17,7 @@ from datetime import timedelta
from hatchet_sdk import Context
from pydantic import BaseModel
from reflector.email import is_email_configured, send_transcript_email
from reflector.hatchet.client import HatchetClientManager
from reflector.hatchet.constants import (
TIMEOUT_HEAVY,
@@ -32,6 +33,7 @@ from reflector.hatchet.workflows.daily_multitrack_pipeline import (
)
from reflector.hatchet.workflows.models import (
ConsentResult,
EmailResult,
TitleResult,
WaveformResult,
WebhookResult,
@@ -361,6 +363,52 @@ async def send_webhook(input: LivePostPipelineInput, ctx: Context) -> WebhookRes
return WebhookResult(webhook_sent=False)
@live_post_pipeline.task(
parents=[final_summaries],
execution_timeout=timedelta(seconds=TIMEOUT_SHORT),
retries=5,
backoff_factor=2.0,
backoff_max_seconds=15,
)
@with_error_handling(TaskName.SEND_EMAIL, set_error_status=False)
async def send_email(input: LivePostPipelineInput, ctx: Context) -> EmailResult:
"""Send transcript email to collected recipients."""
ctx.log(f"send_email: transcript_id={input.transcript_id}")
if not is_email_configured():
ctx.log("send_email skipped (SMTP not configured)")
return EmailResult(skipped=True)
async with fresh_db_connection():
from reflector.db.meetings import meetings_controller # noqa: PLC0415
from reflector.db.recordings import recordings_controller # noqa: PLC0415
from reflector.db.transcripts import transcripts_controller # noqa: PLC0415
transcript = await transcripts_controller.get_by_id(input.transcript_id)
if not transcript:
ctx.log("send_email skipped (transcript not found)")
return EmailResult(skipped=True)
meeting = None
if transcript.meeting_id:
meeting = await meetings_controller.get_by_id(transcript.meeting_id)
if not meeting and transcript.recording_id:
recording = await recordings_controller.get_by_id(transcript.recording_id)
if recording and recording.meeting_id:
meeting = await meetings_controller.get_by_id(recording.meeting_id)
if not meeting or not meeting.email_recipients:
ctx.log("send_email skipped (no email recipients)")
return EmailResult(skipped=True)
await transcripts_controller.update(transcript, {"share_mode": "public"})
count = await send_transcript_email(meeting.email_recipients, transcript)
ctx.log(f"send_email complete: sent {count} emails")
return EmailResult(emails_sent=count)
# --- On failure handler ---

View File

@@ -170,3 +170,10 @@ class WebhookResult(BaseModel):
webhook_sent: bool
skipped: bool = False
response_code: int | None = None
class EmailResult(BaseModel):
"""Result from send_email task."""
emails_sent: int = 0
skipped: bool = False

View File

@@ -195,6 +195,14 @@ class Settings(BaseSettings):
ZULIP_API_KEY: str | None = None
ZULIP_BOT_EMAIL: str | None = None
# Email / SMTP integration (for transcript email notifications)
SMTP_HOST: str | None = None
SMTP_PORT: int = 587
SMTP_USERNAME: str | None = None
SMTP_PASSWORD: str | None = None
SMTP_FROM_EMAIL: str | None = None
SMTP_USE_TLS: bool = True
# Hatchet workflow orchestration (always enabled for multitrack processing)
HATCHET_CLIENT_TOKEN: str | None = None
HATCHET_CLIENT_TLS_STRATEGY: str = "none" # none, tls, mtls

View File

@@ -4,7 +4,7 @@ from typing import Annotated, Any, Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel
from pydantic import BaseModel, EmailStr
import reflector.auth as auth
from reflector.dailyco_api import RecordingType
@@ -151,3 +151,25 @@ async def start_recording(
raise HTTPException(
status_code=500, detail=f"Failed to start recording: {str(e)}"
)
class AddEmailRecipientRequest(BaseModel):
email: EmailStr
@router.post("/meetings/{meeting_id}/email-recipient")
async def add_email_recipient(
meeting_id: str,
request: AddEmailRecipientRequest,
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
):
"""Add an email address to receive the transcript link when processing completes."""
meeting = await meetings_controller.get_by_id(meeting_id)
if not meeting:
raise HTTPException(status_code=404, detail="Meeting not found")
recipients = await meetings_controller.add_email_recipient(
meeting_id, request.email
)
return {"status": "success", "email_recipients": recipients}

View File

@@ -40,6 +40,11 @@ x-backend-env: &backend-env
# Garage S3 credentials — hardcoded test keys, containers are ephemeral
TRANSCRIPT_STORAGE_AWS_ACCESS_KEY_ID: GK0123456789abcdef01234567 # gitleaks:allow
TRANSCRIPT_STORAGE_AWS_SECRET_ACCESS_KEY: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" # gitleaks:allow
# Email / SMTP — Mailpit captures emails without sending
SMTP_HOST: mailpit
SMTP_PORT: "1025"
SMTP_FROM_EMAIL: test@reflector.local
SMTP_USE_TLS: "false"
# NOTE: DAILYCO_STORAGE_AWS_* intentionally NOT set — forces fallback to
# get_transcripts_storage() which has ENDPOINT_URL pointing at Garage.
# Setting them would bypass the endpoint and generate presigned URLs for AWS.
@@ -101,6 +106,14 @@ services:
retries: 10
start_period: 5s
mailpit:
image: axllent/mailpit:latest
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8025/api/v1/messages"]
interval: 5s
timeout: 3s
retries: 5
mock-daily:
build:
context: .
@@ -131,6 +144,8 @@ services:
condition: service_healthy
mock-daily:
condition: service_healthy
mailpit:
condition: service_healthy
volumes:
- server_data:/app/data
@@ -194,6 +209,7 @@ services:
DATABASE_URL: postgresql+asyncpg://reflector:reflector@postgres:5432/reflector
SERVER_URL: http://server:1250
GARAGE_ENDPOINT: http://garage:3900
MAILPIT_URL: http://mailpit:8025
depends_on:
server:
condition: service_started

View File

@@ -17,6 +17,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
SERVER_URL = os.environ.get("SERVER_URL", "http://server:1250")
GARAGE_ENDPOINT = os.environ.get("GARAGE_ENDPOINT", "http://garage:3900")
MAILPIT_URL = os.environ.get("MAILPIT_URL", "http://mailpit:8025")
DATABASE_URL = os.environ.get(
"DATABASE_URL_ASYNC",
os.environ.get(
@@ -114,3 +115,44 @@ async def _poll_transcript_status(
def poll_transcript_status():
"""Returns the poll_transcript_status async helper function."""
return _poll_transcript_status
@pytest_asyncio.fixture
async def mailpit_client():
"""HTTP client for Mailpit API — query captured emails."""
async with httpx.AsyncClient(
base_url=MAILPIT_URL,
timeout=httpx.Timeout(10.0),
) as client:
# Clear inbox before each test
await client.delete("/api/v1/messages")
yield client
async def _poll_mailpit_messages(
mailpit: httpx.AsyncClient,
to_email: str,
max_wait: int = 30,
interval: int = 2,
) -> list[dict]:
"""
Poll Mailpit API until at least one message is delivered to the given address.
Returns the list of matching messages.
"""
elapsed = 0
while elapsed < max_wait:
resp = await mailpit.get("/api/v1/messages", params={"query": f"to:{to_email}"})
resp.raise_for_status()
data = resp.json()
messages = data.get("messages", [])
if messages:
return messages
await asyncio.sleep(interval)
elapsed += interval
raise TimeoutError(f"No email delivered to {to_email} within {max_wait}s")
@pytest_asyncio.fixture
def poll_mailpit_messages():
"""Returns the poll_mailpit_messages async helper function."""
return _poll_mailpit_messages

View File

@@ -4,10 +4,12 @@ Integration test: Multitrack → DailyMultitrackPipeline → full processing.
Exercises: S3 upload → DB recording setup → process endpoint →
Hatchet DiarizationPipeline → mock Daily API → whisper per-track transcription →
diarization → mixdown → LLM summarization/topics → status "ended".
Also tests email transcript notification via Mailpit SMTP sink.
"""
import json
from datetime import datetime, timezone
import uuid
from datetime import datetime, timedelta, timezone
import pytest
from sqlalchemy import text
@@ -22,6 +24,9 @@ TRACK_KEYS = [
]
TEST_EMAIL = "integration-test@reflector.local"
@pytest.mark.asyncio
async def test_multitrack_pipeline_end_to_end(
api_client,
@@ -30,6 +35,8 @@ async def test_multitrack_pipeline_end_to_end(
test_records_dir,
bucket_name,
poll_transcript_status,
mailpit_client,
poll_mailpit_messages,
):
"""Set up multitrack recording in S3/DB and verify the full pipeline completes."""
# 1. Upload test audio as two separate tracks to Garage S3
@@ -52,16 +59,41 @@ async def test_multitrack_pipeline_end_to_end(
transcript = resp.json()
transcript_id = transcript["id"]
# 3. Insert Recording row and link to transcript via direct DB access
# 3. Insert Meeting, Recording, and link to transcript via direct DB access
recording_id = f"rec-integration-{transcript_id[:8]}"
meeting_id = str(uuid.uuid4())
now = datetime.now(timezone.utc)
async with db_engine.begin() as conn:
# Insert recording with track_keys
# Insert meeting with email_recipients for email notification test
await conn.execute(
text("""
INSERT INTO recording (id, bucket_name, object_key, recorded_at, status, track_keys)
VALUES (:id, :bucket_name, :object_key, :recorded_at, :status, CAST(:track_keys AS json))
INSERT INTO meeting (
id, room_name, room_url, host_room_url,
start_date, end_date, platform, email_recipients
)
VALUES (
:id, :room_name, :room_url, :host_room_url,
:start_date, :end_date, :platform, CAST(:email_recipients AS json)
)
"""),
{
"id": meeting_id,
"room_name": "integration-test-room",
"room_url": "https://test.daily.co/integration-test-room",
"host_room_url": "https://test.daily.co/integration-test-room",
"start_date": now,
"end_date": now + timedelta(hours=1),
"platform": "daily",
"email_recipients": json.dumps([TEST_EMAIL]),
},
)
# Insert recording with track_keys, linked to meeting
await conn.execute(
text("""
INSERT INTO recording (id, bucket_name, object_key, recorded_at, status, track_keys, meeting_id)
VALUES (:id, :bucket_name, :object_key, :recorded_at, :status, CAST(:track_keys AS json), :meeting_id)
"""),
{
"id": recording_id,
@@ -70,6 +102,7 @@ async def test_multitrack_pipeline_end_to_end(
"recorded_at": now,
"status": "completed",
"track_keys": json.dumps(TRACK_KEYS),
"meeting_id": meeting_id,
},
)
@@ -127,3 +160,22 @@ async def test_multitrack_pipeline_end_to_end(
assert (
len(participants) >= 2
), f"Expected at least 2 speakers for multitrack, got {len(participants)}"
# 7. Verify email transcript notification
# The send_email pipeline task should have:
# a) Set the transcript to public share_mode
# b) Sent an email to TEST_EMAIL via Mailpit
transcript_resp = await api_client.get(f"/transcripts/{transcript_id}")
transcript_resp.raise_for_status()
transcript_data = transcript_resp.json()
assert (
transcript_data.get("share_mode") == "public"
), "Transcript should be set to public when email recipients exist"
# Poll Mailpit for the delivered email (send_email task runs async after finalize)
messages = await poll_mailpit_messages(mailpit_client, TEST_EMAIL, max_wait=30)
assert len(messages) >= 1, "Should have received at least 1 email"
email_msg = messages[0]
assert (
"Transcript Ready" in email_msg.get("Subject", "")
), f"Email subject should contain 'Transcript Ready', got: {email_msg.get('Subject')}"

13
server/uv.lock generated
View File

@@ -188,6 +188,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" },
]
[[package]]
name = "aiosmtplib"
version = "5.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/ad/240a7ce4e50713b111dff8b781a898d8d4770e5d6ad4899103f84c86005c/aiosmtplib-5.1.0.tar.gz", hash = "sha256:2504a23b2b63c9de6bc4ea719559a38996dba68f73f6af4eb97be20ee4c5e6c4", size = 66176, upload-time = "2026-01-25T01:51:11.408Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/82/70f2c452acd7ed18c558c8ace9a8cf4fdcc70eae9a41749b5bdc53eb6f45/aiosmtplib-5.1.0-py3-none-any.whl", hash = "sha256:368029440645b486b69db7029208a7a78c6691b90d24a5332ddba35d9109d55b", size = 27778, upload-time = "2026-01-25T01:51:10.026Z" },
]
[[package]]
name = "aiosqlite"
version = "0.21.0"
@@ -3343,10 +3352,12 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aiohttp-cors" },
{ name = "aiortc" },
{ name = "aiosmtplib" },
{ name = "alembic" },
{ name = "av" },
{ name = "celery" },
{ name = "databases", extra = ["aiosqlite", "asyncpg"] },
{ name = "email-validator" },
{ name = "fastapi", extra = ["standard"] },
{ name = "fastapi-pagination" },
{ name = "hatchet-sdk" },
@@ -3422,10 +3433,12 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.9.0" },
{ name = "aiohttp-cors", specifier = ">=0.7.0" },
{ name = "aiortc", specifier = ">=1.5.0" },
{ name = "aiosmtplib", specifier = ">=3.0.0" },
{ name = "alembic", specifier = ">=1.11.3" },
{ name = "av", specifier = ">=15.0.0" },
{ name = "celery", specifier = ">=5.3.4" },
{ name = "databases", extras = ["aiosqlite", "asyncpg"], specifier = ">=0.7.0" },
{ name = "email-validator", specifier = ">=2.0.0" },
{ name = "fastapi", extras = ["standard"], specifier = ">=0.100.1" },
{ name = "fastapi-pagination", specifier = ">=0.14.2" },
{ name = "hatchet-sdk", specifier = "==1.22.16" },