Skip to main content
Glama
invitations.py17.4 kB
""" Invitation Management Routes This module handles invitation creation, acceptance, and management for the selfmemory project. Supports both organization-level and project-level invitations. Clean Code Principles: - No fallback mechanisms (fails explicitly) - No mock data - Clear function names and comprehensive error handling """ import logging import os import secrets import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from ..config import config from ..dependencies import AuthContext, authenticate_api_key, mongo_db from ..utils.datetime_helpers import is_expired from ..utils.permission_helpers import get_user_by_kratos_id from ..utils.rate_limiter import limiter from ..utils.user_helpers import ensure_user_exists from .invitation_helpers import ( add_user_to_organization, add_user_to_projects_from_invitation, get_invitation_response_details, mark_invitation_accepted, validate_invitation_for_acceptance, ) router = APIRouter(prefix="/api/invitations", tags=["invitations"]) logger = logging.getLogger(__name__) # ============================================================================ # Pydantic Models # ============================================================================ class InvitationResponse(BaseModel): """Response model for invitation details""" invitation_id: str token: str email: str organization_id: str organization_name: str project_id: str | None = None project_name: str | None = None role: str invited_by_email: str status: str expires_at: str created_at: str class AcceptInvitationRequest(BaseModel): """Request model for accepting an invitation""" # No fields needed - token is in URL, user info from auth # ============================================================================ # Helper Functions # ============================================================================ def generate_invitation_token() -> str: """ Generate a secure random token for invitation links. Returns: str: A URL-safe random token (32 bytes = 256 bits) """ return secrets.token_urlsafe(32) def get_invitation_by_token(token: str) -> dict | None: """ Retrieve invitation by token. Args: token: The invitation token Returns: dict: The invitation document or None if not found """ return mongo_db.invitations.find_one({"token": token}) def is_invitation_expired(invitation: dict) -> bool: """ Check if an invitation has expired. Args: invitation: The invitation document Returns: bool: True if expired, False otherwise """ expires_at = invitation.get("expiresAt") if not expires_at: return False return is_expired(expires_at) def send_invitation_email( email: str, organization_name: str, project_name: str | None, role: str, invited_by_email: str, invitation_token: str, ) -> None: """ Send invitation email to the user via SMTP. Reads SMTP configuration from environment variables: - SMTP_HOST: SMTP server hostname - SMTP_PORT: SMTP server port - SMTP_USERNAME: SMTP username - SMTP_PASSWORD: SMTP password - SMTP_FROM_EMAIL: From email address - SMTP_FROM_NAME: From name - SMTP_USE_TLS: Whether to use TLS - FRONTEND_URL: Frontend URL for invitation links If SMTP is not configured, logs invitation details instead. Args: email: Recipient email address organization_name: Name of the organization project_name: Name of the project (if project-specific invite) role: Role being offered invited_by_email: Email of the person who sent the invite invitation_token: Token for the invitation link """ # Get SMTP configuration from environment smtp_host = os.getenv("SMTP_HOST") smtp_port = int(os.getenv("SMTP_PORT", "587")) smtp_username = os.getenv("SMTP_USERNAME") smtp_password = os.getenv("SMTP_PASSWORD") smtp_from_email = os.getenv("SMTP_FROM_EMAIL", "noreply@selfmemory.com") smtp_from_name = os.getenv("SMTP_FROM_NAME", "SelfMemory") smtp_use_tls = os.getenv("SMTP_USE_TLS", "true").lower() == "true" frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") invitation_link = f"{frontend_url}/accept-invitation/{invitation_token}" # If SMTP is not configured, log instead if not smtp_host or not smtp_username or not smtp_password: logger.info( f"📧 INVITATION EMAIL (SMTP not configured - logging only):\n" f" To: {email}\n" f" From: {invited_by_email}\n" f" Organization: {organization_name}\n" f" Project: {project_name or 'N/A'}\n" f" Role: {role}\n" f" Link: {invitation_link}\n" ) return # Create email message msg = MIMEMultipart("alternative") msg["Subject"] = f"Invitation to join {organization_name}" msg["From"] = f"{smtp_from_name} <{smtp_from_email}>" msg["To"] = email # Create plain text version text_body = f""" Hello, {invited_by_email} has invited you to join {organization_name}{f" - {project_name}" if project_name else ""} as a {role}. Click the link below to accept the invitation: {invitation_link} This invitation will expire in 24 hours. Best regards, The SelfMemory Team """ # Create HTML version html_body = f""" <!DOCTYPE html> <html> <head> <style> body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }} .container {{ max-width: 600px; margin: 0 auto; padding: 20px; }} .header {{ background-color: #4F46E5; color: white; padding: 20px; text-align: center; }} .content {{ background-color: #f9fafb; padding: 30px; }} .button {{ background-color: #4F46E5; color: white; padding: 12px 24px; text-decoration: none; border-radius: 6px; display: inline-block; margin: 20px 0; }} .footer {{ text-align: center; color: #6b7280; font-size: 12px; margin-top: 30px; }} </style> </head> <body> <div class="container"> <div class="header"> <h1>You've Been Invited!</h1> </div> <div class="content"> <p>Hello,</p> <p><strong>{invited_by_email}</strong> has invited you to join:</p> <ul> <li><strong>Organization:</strong> {organization_name}</li> {f"<li><strong>Project:</strong> {project_name}</li>" if project_name else ""} <li><strong>Role:</strong> {role}</li> </ul> <p>Click the button below to accept the invitation:</p> <a href="{invitation_link}" class="button">Accept Invitation</a> <p style="color: #6b7280; font-size: 14px;"> This invitation will expire in 24 hours. </p> </div> <div class="footer"> <p>This is an automated message from SelfMemory.</p> </div> </div> </body> </html> """ # Attach both versions part1 = MIMEText(text_body, "plain") part2 = MIMEText(html_body, "html") msg.attach(part1) msg.attach(part2) # Connect to SMTP server and send email # Port 465 uses SSL, port 587 uses STARTTLS # Timeout set to 10 seconds to fail fast if SMTP unreachable if smtp_port == 465: # Use SMTP_SSL for port 465 with smtplib.SMTP_SSL( smtp_host, smtp_port, timeout=config.email.SMTP_TIMEOUT ) as server: server.login(smtp_username, smtp_password) server.send_message(msg) else: # Use SMTP with STARTTLS for port 587 with smtplib.SMTP( smtp_host, smtp_port, timeout=config.email.SMTP_TIMEOUT ) as server: if smtp_use_tls: server.starttls() server.login(smtp_username, smtp_password) server.send_message(msg) logger.info(f"✅ Invitation email sent to {email}") # ============================================================================ # Authenticated Endpoints (Must come BEFORE parameterized routes) # ============================================================================ @router.get("/pending", response_model=list[InvitationResponse]) def list_pending_invitations(auth: AuthContext = Depends(authenticate_api_key)): """ List pending invitations for the authenticated user. Returns all invitations sent to the user's email that haven't been accepted yet and haven't expired. """ # Get user document using helper (handles migration dual-format) try: user = get_user_by_kratos_id(mongo_db, auth.user_id) except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) from None user_email = user.get("email") if not user_email: return [] # Find all pending invitations for this email invitations = list( mongo_db.invitations.find( { "email": user_email, "status": "pending", } ) ) # Filter out expired invitations and mark them valid_invitations = [] for invitation in invitations: if is_invitation_expired(invitation): # Mark as expired mongo_db.invitations.update_one( {"_id": invitation["_id"]}, {"$set": {"status": "expired"}}, ) continue # Get organization details organization = mongo_db.organizations.find_one( {"_id": invitation["organizationId"]} ) if not organization: continue # Get project details if project-specific invitation project_name = None if invitation.get("projectId"): project = mongo_db.projects.find_one({"_id": invitation["projectId"]}) if project: project_name = project["name"] # Get inviter details inviter = mongo_db.users.find_one({"_id": invitation["invitedBy"]}) inviter_email = inviter["email"] if inviter else "Unknown" valid_invitations.append( InvitationResponse( invitation_id=str(invitation["_id"]), token=invitation["token"], email=invitation["email"], organization_id=str(invitation["organizationId"]), organization_name=organization["name"], project_id=str(invitation["projectId"]) if invitation.get("projectId") else None, project_name=project_name, role=invitation["role"], invited_by_email=inviter_email, status=invitation["status"], expires_at=invitation["expiresAt"].isoformat(), created_at=invitation["createdAt"].isoformat(), ) ) logger.info( f"✅ Retrieved {len(valid_invitations)} pending invitations for user {auth.user_id}" ) return valid_invitations @router.post("/{token}/accept") @limiter.limit(config.rate_limit.INVITATION_ACCEPT) def accept_invitation( request: Request, token: str, auth: AuthContext = Depends(authenticate_api_key), ): """ Accept an invitation (authenticated endpoint). Rate limited to prevent abuse (3 requests per minute). Scenarios: - User accepts pending invitation → Add to org/project, mark as accepted - User's email doesn't match invitation → Return 403 Forbidden - Invitation expired → Return 410 Gone - Invitation already accepted → Return 410 Gone - Invalid token → Return 404 Not Found """ # Look up invitation by token invitation = get_invitation_by_token(token) if not invitation: raise HTTPException(status_code=404, detail="Invitation not found") # Ensure user record exists (critical for OAuth flows) # This creates the user if they don't exist yet user = ensure_user_exists(mongo_db, auth.user_id, invitation["email"]) user_obj_id = user["_id"] # Validate invitation (checks expiry, status, email match) validate_invitation_for_acceptance(invitation, user.get("email")) # Add user to organization add_user_to_organization(user_obj_id, invitation) # Add user to projects (handles both single project and multi-project invitations) projects_added = add_user_to_projects_from_invitation(user_obj_id, invitation) # Mark invitation as accepted mark_invitation_accepted(invitation["_id"]) # Get response details response_details = get_invitation_response_details(invitation) # CREATE NOTIFICATION for inviter from datetime import timedelta from ..utils.datetime_helpers import utc_now notification_doc = { "type": "invitation_accepted", "userId": invitation["invitedBy"], # Inviter receives notification "relatedUserId": user_obj_id, # Accepter "organizationId": invitation["organizationId"], "projectId": invitation.get("projectId"), "invitationId": invitation["_id"], "title": f"{user.get('email', 'A user')} accepted your invitation", "message": f"{user.get('email')} joined {response_details.get('organization_name', 'organization')}" + ( f" - {response_details.get('project_name')}" if response_details.get("project_name") else "" ) + f" as {invitation['role']}", "metadata": { "email": user.get("email"), "organizationName": response_details.get("organization_name"), "projectName": response_details.get("project_name"), "role": invitation["role"], }, "read": False, "readAt": None, "createdAt": utc_now(), "expiresAt": utc_now() + timedelta(days=30), # Auto-delete after 30 days } mongo_db.notifications.insert_one(notification_doc) logger.info( f"Created acceptance notification for inviter {str(invitation['invitedBy'])}" ) logger.info( f"✅ Invitation accepted by user {auth.user_id}, " f"added to {len(projects_added)} projects" ) return { "message": "Invitation accepted successfully", **response_details, "projects_added": len(projects_added), } # ============================================================================ # Public Endpoints (No Authentication Required) # ============================================================================ @router.get("/{token}", response_model=InvitationResponse) def get_invitation_details(token: str): """ Get invitation details by token (PUBLIC endpoint - no auth required). This endpoint allows anyone with the invitation link to view the details before deciding whether to accept. Scenarios: - Valid token, pending invitation → Return invitation details - Expired invitation → Return 410 Gone - Already accepted invitation → Return 410 Gone - Invalid token → Return 404 Not Found """ # Look up invitation by token invitation = get_invitation_by_token(token) if not invitation: raise HTTPException(status_code=404, detail="Invitation not found") # Check if already accepted if invitation["status"] == "accepted": raise HTTPException( status_code=410, detail="This invitation has already been accepted", ) # Check if expired if is_invitation_expired(invitation): # Mark as expired mongo_db.invitations.update_one( {"_id": invitation["_id"]}, {"$set": {"status": "expired"}}, ) raise HTTPException( status_code=410, detail="This invitation has expired", ) # Get organization details organization = mongo_db.organizations.find_one( {"_id": invitation["organizationId"]} ) if not organization: raise HTTPException(status_code=404, detail="Organization not found") # Get project details if project-specific invitation project_name = None if invitation.get("projectId"): project = mongo_db.projects.find_one({"_id": invitation["projectId"]}) if project: project_name = project["name"] # Get inviter details inviter = mongo_db.users.find_one({"_id": invitation["invitedBy"]}) inviter_email = inviter["email"] if inviter else "Unknown" logger.info(f"✅ Retrieved invitation details for token: {token[:10]}...") return InvitationResponse( invitation_id=str(invitation["_id"]), token=invitation["token"], email=invitation["email"], organization_id=str(invitation["organizationId"]), organization_name=organization["name"], project_id=str(invitation["projectId"]) if invitation.get("projectId") else None, project_name=project_name, role=invitation["role"], invited_by_email=inviter_email, status=invitation["status"], expires_at=invitation["expiresAt"].isoformat(), created_at=invitation["createdAt"].isoformat(), )

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/shrijayan/SelfMemory'

If you have feedback or need assistance with the MCP directory API, please join our Discord server