feat: add email, scheduler, and Zulip integration services

- Add email service for sending meeting invites with ICS attachments
- Add scheduler for background calendar sync jobs
- Add Zulip service for meeting notifications
- Make ics_url optional for participants
- Add /api/schedule endpoint with 2-hour lead time validation
- Update frontend to support scheduling flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Joyce
2026-01-21 13:52:02 -05:00
parent d585cf8613
commit 26311c867a
17 changed files with 403 additions and 59 deletions

View File

@@ -22,7 +22,7 @@ def upgrade() -> None:
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("email", sa.String(255), nullable=False),
sa.Column("ics_url", sa.Text(), nullable=False),
sa.Column("ics_url", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint("id"),

View File

@@ -15,6 +15,9 @@ dependencies = [
"python-dateutil>=2.9.0",
"pydantic[email]>=2.10.0",
"pydantic-settings>=2.6.0",
"apscheduler>=3.10.4",
"aiosmtplib>=3.0.1",
"zulip>=0.9.0",
]
[project.optional-dependencies]

View File

@@ -96,7 +96,7 @@ async def calculate_availability(
availability = "none"
slots.append({
"day": day_name,
"day": slot_start.strftime("%Y-%m-%d"),
"hour": hour,
"availability": availability,
"availableParticipants": available_participants,

View File

@@ -6,6 +6,18 @@ class Settings(BaseSettings):
sync_database_url: str = "postgresql://postgres:postgres@db:5432/availability"
ics_refresh_interval_minutes: int = 15
# SMTP Settings
smtp_host: str | None = None
smtp_port: int = 587
smtp_user: str | None = None
smtp_password: str | None = None
# Zulip Settings
zulip_site: str | None = None
zulip_email: str | None = None
zulip_api_key: str | None = None
zulip_stream: str = "general"
class Config:
env_file = ".env"

View File

@@ -0,0 +1,82 @@
import logging
import uuid
from datetime import datetime, timezone
import aiosmtplib
from email.message import EmailMessage
from icalendar import Calendar, Event, vCalAddress, vText
from app.config import settings
logger = logging.getLogger(__name__)
async def send_meeting_invite(
participants: list[dict],
title: str,
description: str,
start_time: datetime,
end_time: datetime,
) -> bool:
if not settings.smtp_host or not settings.smtp_user or not settings.smtp_password:
logger.warning("SMTP credentials not configured. Skipping email invite.")
return False
# Create the ICS content
cal = Calendar()
cal.add('prodid', '-//Common Availability//m.com//')
cal.add('version', '2.0')
cal.add('method', 'REQUEST')
event = Event()
event.add('summary', title)
event.add('dtstart', start_time)
event.add('dtend', end_time)
event.add('dtstamp', datetime.now(timezone.utc))
event.add('description', description)
event.add('uid', str(uuid.uuid4()))
event.add('organizer', vCalAddress(f'MAILTO:{settings.smtp_user}'))
attendee_emails = []
for p in participants:
attendee = vCalAddress(f'MAILTO:{p["email"]}')
attendee.params['cn'] = vText(p["name"])
attendee.params['ROLE'] = vText('REQ-PARTICIPANT')
event.add('attendee', attendee, encode=0)
attendee_emails.append(p["email"])
cal.add_component(event)
ics_data = cal.to_ical()
# Create Email
msg = EmailMessage()
msg["Subject"] = f"Invitation: {title}"
msg["From"] = settings.smtp_user
msg["To"] = ", ".join(attendee_emails)
msg.set_content(
f"You have been invited to: {title}\n"
f"When: {start_time.strftime('%Y-%m-%d %H:%M %Z')}\n\n"
f"{description}"
)
# Attach ICS
msg.add_attachment(
ics_data,
maintype="text",
subtype="calendar",
filename="invite.ics",
params={"method": "REQUEST"}
)
# Send
try:
await aiosmtplib.send(
msg,
hostname=settings.smtp_host,
port=settings.smtp_port,
username=settings.smtp_user,
password=settings.smtp_password,
start_tls=True
)
logger.info(f"Sent meeting invites to {len(attendee_emails)} participants")
return True
except Exception as e:
logger.error(f"Failed to send email invite: {e}")
return False

View File

@@ -16,12 +16,24 @@ from app.schemas import (
ParticipantCreate,
ParticipantResponse,
SyncResponse,
ScheduleRequest,
)
from app.scheduler import start_scheduler, stop_scheduler
from app.email_service import send_meeting_invite
from app.zulip_service import send_zulip_notification
from contextlib import asynccontextmanager
from datetime import datetime, timezone, timedelta
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
app = FastAPI(title="Common Availability API")
@asynccontextmanager
async def lifespan(app: FastAPI):
start_scheduler()
yield
stop_scheduler()
app = FastAPI(title="Common Availability API", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
@@ -57,7 +69,8 @@ async def create_participant(
await db.refresh(participant)
try:
await sync_participant_calendar(db, participant)
if participant.ics_url:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.warning(f"Initial sync failed for {participant.email}: {e}")
@@ -121,7 +134,57 @@ async def sync_participant(participant_id: UUID, db: AsyncSession = Depends(get_
raise HTTPException(status_code=404, detail="Participant not found")
try:
count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count}
if participant.ics_url:
count = await sync_participant_calendar(db, participant)
return {"status": "success", "blocks_synced": count}
return {"status": "skipped", "message": "No ICS URL provided"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@app.post("/api/schedule")
async def schedule_meeting(
data: ScheduleRequest, db: AsyncSession = Depends(get_db)
):
# 1. Validate Lead Time (2 hours)
min_start_time = datetime.now(timezone.utc) + timedelta(hours=2)
if data.start_time.replace(tzinfo=timezone.utc) < min_start_time:
raise HTTPException(
status_code=400,
detail="Meetings must be scheduled at least 2 hours in advance."
)
# 2. Fetch Participants
result = await db.execute(
select(Participant).where(Participant.id.in_(data.participant_ids))
)
participants = result.scalars().all()
if len(participants) != len(data.participant_ids):
raise HTTPException(status_code=400, detail="Some participants not found")
participant_dicts = [
{"name": p.name, "email": p.email} for p in participants
]
participant_names = [p.name for p in participants]
# 3. Send Notifications
email_success = await send_meeting_invite(
participant_dicts,
data.title,
data.description,
data.start_time,
data.end_time
)
zulip_success = send_zulip_notification(
data.title,
data.start_time,
participant_names
)
return {
"status": "success",
"email_sent": email_success,
"zulip_sent": zulip_success
}

View File

@@ -18,7 +18,7 @@ class Participant(Base):
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
ics_url: Mapped[str] = mapped_column(Text, nullable=False)
ics_url: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime, default=datetime.utcnow, nullable=False
)

View File

@@ -0,0 +1,39 @@
import logging
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.interval import IntervalTrigger
from sqlalchemy import select
from app.database import async_session_maker
from app.models import Participant
from app.ics_service import sync_participant_calendar
from app.config import settings
logger = logging.getLogger(__name__)
scheduler = AsyncIOScheduler()
async def run_sync_job():
logger.info("Starting background calendar sync...")
async with async_session_maker() as db:
result = await db.execute(select(Participant).where(Participant.ics_url.is_not(None)))
participants = result.scalars().all()
for participant in participants:
try:
await sync_participant_calendar(db, participant)
except Exception as e:
logger.error(f"Background sync failed for {participant.email}: {e}")
logger.info("Background calendar sync completed.")
def start_scheduler():
scheduler.add_job(
run_sync_job,
IntervalTrigger(minutes=settings.ics_refresh_interval_minutes),
id="calendar_sync",
replace_existing=True
)
scheduler.start()
logger.info("Scheduler started.")
def stop_scheduler():
scheduler.shutdown()
logger.info("Scheduler stopped.")

View File

@@ -7,14 +7,14 @@ from pydantic import BaseModel, EmailStr
class ParticipantCreate(BaseModel):
name: str
email: EmailStr
ics_url: str
ics_url: str | None = None
class ParticipantResponse(BaseModel):
id: UUID
name: str
email: str
ics_url: str
ics_url: str | None
created_at: datetime
updated_at: datetime
@@ -39,3 +39,11 @@ class AvailabilityResponse(BaseModel):
class SyncResponse(BaseModel):
results: dict[str, dict]
class ScheduleRequest(BaseModel):
participant_ids: list[UUID]
title: str
description: str
start_time: datetime
end_time: datetime

View File

@@ -0,0 +1,51 @@
import logging
import zulip
from app.config import settings
from datetime import datetime
logger = logging.getLogger(__name__)
def send_zulip_notification(
title: str,
start_time: datetime,
participant_names: list[str],
) -> bool:
if not settings.zulip_site or not settings.zulip_api_key or not settings.zulip_email:
logger.warning("Zulip credentials not configured. Skipping notification.")
return False
try:
client = zulip.Client(
email=settings.zulip_email,
api_key=settings.zulip_api_key,
site=settings.zulip_site
)
formatted_time = start_time.strftime('%Y-%m-%d %H:%M %Z')
people = ", ".join(participant_names)
content = (
f"📅 **Meeting Scheduled**\n"
f"**What:** {title}\n"
f"**When:** {formatted_time}\n"
f"**Who:** {people}"
)
request = {
"type": "stream",
"to": settings.zulip_stream,
"topic": "Meeting Announcements",
"content": content
}
result = client.send_message(request)
if result.get("result") == "success":
logger.info("Sent Zulip notification")
return True
else:
logger.error(f"Zulip API error: {result.get('msg')}")
return False
except Exception as e:
logger.error(f"Failed to send Zulip notification: {e}")
return False