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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
82
backend/src/app/email_service.py
Normal file
82
backend/src/app/email_service.py
Normal 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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
39
backend/src/app/scheduler.py
Normal file
39
backend/src/app/scheduler.py
Normal 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.")
|
||||
@@ -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
|
||||
|
||||
51
backend/src/app/zulip_service.py
Normal file
51
backend/src/app/zulip_service.py
Normal 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
|
||||
Reference in New Issue
Block a user