From 205e423b56df984e06f670ab648277fcbde32f2f Mon Sep 17 00:00:00 2001 From: deepvasoya Date: Fri, 16 May 2025 18:19:14 +0530 Subject: [PATCH] feat: sns webhook feat: text content type handler feat: email blocking table --- apis/__init__.py | 6 +- apis/endpoints/sns.py | 14 ++ main.py | 18 +-- middleware/CustomRequestTypeMiddleware.py | 41 ++++++ models/BlockedEmail.py | 13 ++ models/__init__.py | 2 + schemas/BaseSchemas.py | 9 ++ services/authService.py | 40 +++++- services/emailService.py | 103 +++++++++++++++ templates/__init.py | 0 templates/htmlTemplatest.py | 153 ++++++++++++++++++++++ 11 files changed, 383 insertions(+), 16 deletions(-) create mode 100644 apis/endpoints/sns.py create mode 100644 middleware/CustomRequestTypeMiddleware.py create mode 100644 models/BlockedEmail.py create mode 100644 services/emailService.py create mode 100644 templates/__init.py create mode 100644 templates/htmlTemplatest.py diff --git a/apis/__init__.py b/apis/__init__.py index 815469e..1ed7000 100644 --- a/apis/__init__.py +++ b/apis/__init__.py @@ -2,10 +2,12 @@ from fastapi import APIRouter, Depends, Security from middleware.auth_dependency import auth_required from fastapi.security import HTTPBearer +from apis.endpoints import sns + # Import the security scheme bearer_scheme = HTTPBearer(scheme_name="Bearer Authentication") -from .endpoints import clinics, doctors, calender, appointments, patients, admin, auth, s3, users, clinicDoctor, dashboard, call_transcripts, notifications +from .endpoints import clinics, doctors, calender, appointments, patients, admin, auth, s3, users, clinicDoctor, dashboard, call_transcripts, notifications,sns api_router = APIRouter() # api_router.include_router(twilio.router, prefix="/twilio") @@ -19,6 +21,8 @@ api_router.include_router(appointments.router, prefix="/appointments", tags=["ap api_router.include_router(patients.router, prefix="/patients", tags=["patients"]) +api_router.include_router(sns.router, prefix="/sns", tags=["sns"], include_in_schema=False) + api_router.include_router( admin.router, prefix="/admin", diff --git a/apis/endpoints/sns.py b/apis/endpoints/sns.py new file mode 100644 index 0000000..8a5ae02 --- /dev/null +++ b/apis/endpoints/sns.py @@ -0,0 +1,14 @@ +from typing import Optional +from fastapi import APIRouter, Body, Header +from fastapi import Request +from services.authService import AuthService +from schemas.BaseSchemas import SNSBase +import json +router = APIRouter() + +@router.post("/") +async def send_sms(request: Request): + body = await request.body() + body = json.loads(body) + AuthService().blockEmailSNS(body) + return "OK" diff --git a/main.py b/main.py index 3c35300..a3dbfd6 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,9 @@ import dotenv -from fastapi import FastAPI, Security +from fastapi import FastAPI from contextlib import asynccontextmanager import logging from fastapi.middleware.cors import CORSMiddleware -from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from fastapi.security import HTTPBearer # db from database import Base, engine @@ -13,6 +13,7 @@ from apis import api_router # middleware from middleware.ErrorHandlerMiddleware import ErrorHandlerMiddleware, configure_exception_handlers +from middleware.CustomRequestTypeMiddleware import TextPlainMiddleware dotenv.load_dotenv() @@ -45,16 +46,6 @@ app = FastAPI( title="Twillio Voice API", description="API for Twillio Voice application", version="1.0.0", - # Define security scheme for Swagger UI - openapi_tags=[ - {"name": "admin", "description": "Admin operations requiring authentication"}, - {"name": "auth", "description": "Authentication operations"}, - {"name": "clinics", "description": "Operations with clinics"}, - {"name": "doctors", "description": "Operations with doctors"}, - {"name": "calender", "description": "Calendar operations"}, - {"name": "appointments", "description": "Operations with appointments"}, - {"name": "patients", "description": "Operations with patients"} - ], swagger_ui_parameters={"defaultModelsExpandDepth": -1} ) @@ -67,6 +58,9 @@ app.add_middleware( allow_headers=["*"], # Allows all headers ) +# Custom request type middleware +app.add_middleware(TextPlainMiddleware) + # Error handler middleware app.add_middleware(ErrorHandlerMiddleware) diff --git a/middleware/CustomRequestTypeMiddleware.py b/middleware/CustomRequestTypeMiddleware.py new file mode 100644 index 0000000..c0416c0 --- /dev/null +++ b/middleware/CustomRequestTypeMiddleware.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI, Request, Response +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +class TextPlainMiddleware(BaseHTTPMiddleware): + def __init__(self, app: ASGIApp): + super().__init__(app) + + async def dispatch(self, request: Request, call_next): + # Check if content type is text/* + content_type = request.headers.get("content-type", "") + + if content_type and content_type.startswith("text/"): + # Store the original receive method + original_receive = request._receive + + # Create a modified receive that will store the body + body = b"" + async def receive(): + nonlocal body + message = await original_receive() + if message["type"] == "http.request": + body += message.get("body", b"") + # Update body to be empty so it won't be processed by other middleware + # message["body"] = b"" + return message + + # Replace the receive method + request._receive = receive + + # Process the request + response = await call_next(request) + + # After the response is generated, we can access the full body + # and attach it to the request state for the route to access + request.state.text_body = body.decode("utf-8") + + return response + else: + # For other content types, proceed as normal + return await call_next(request) \ No newline at end of file diff --git a/models/BlockedEmail.py b/models/BlockedEmail.py new file mode 100644 index 0000000..3ec1d5f --- /dev/null +++ b/models/BlockedEmail.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, String + +from database import Base +from .CustomBase import CustomBase + +class BlockedEmail(Base, CustomBase): + __tablename__ = "blocked_emails" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True) + reason = Column(String) + severity = Column(String) + \ No newline at end of file diff --git a/models/__init__.py b/models/__init__.py index 7f9c5f9..d8f6762 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -10,6 +10,7 @@ from .ClinicDoctors import ClinicDoctors from .Notifications import Notifications from .CallTranscripts import CallTranscripts from .Fcm import Fcm +from .BlockedEmail import BlockedEmail __all__ = [ "Users", @@ -24,4 +25,5 @@ __all__ = [ "Notifications", "CallTranscripts", "Fcm", + "BlockedEmail", ] diff --git a/schemas/BaseSchemas.py b/schemas/BaseSchemas.py index 78134c7..dc61634 100644 --- a/schemas/BaseSchemas.py +++ b/schemas/BaseSchemas.py @@ -4,6 +4,15 @@ from typing import List, Optional from pydantic import BaseModel, EmailStr from enums.enums import AppointmentStatus, ClinicDoctorStatus, ClinicDoctorType, ClinicUserRoles, UserType, Integration +class SNSBase(BaseModel): + Type: str + MessageId: str + Token: str + TopicArn: str + SubscribeURL: str + Message: str + + class AuthBase(BaseModel): email: EmailStr diff --git a/services/authService.py b/services/authService.py index bb319e3..ae79d86 100644 --- a/services/authService.py +++ b/services/authService.py @@ -1,15 +1,23 @@ -from database import get_db +import json +import urllib.request +from sqlalchemy.orm import Session from services.jwtService import create_jwt_token from services.userServices import UserServices +from models import BlockedEmail from utils.password_utils import verify_password from schemas.CreateSchemas import UserCreate from schemas.BaseSchemas import AuthBase from exceptions.unauthorized_exception import UnauthorizedException +from database import get_db + + + class AuthService: def __init__(self): self.user_service = UserServices() - + self.db = next(get_db()) + def login(self, data: AuthBase) -> str: # get user @@ -39,4 +47,30 @@ class AuthService: "clinicId": response.created_clinics[0].id } token = create_jwt_token(user) - return token \ No newline at end of file + return token + + def blockEmailSNS(self, body: str): + # confirm subscription + if body["Type"] == "SubscriptionConfirmation": + urllib.request.urlopen(body["SubscribeURL"]) + + # disable automatic unsubscribe confirmation by activating subscription again + elif body["Type"] == "UnsubscribeConfirmation": + urllib.request.urlopen(body["SubscribeURL"]) + + # handle bounce notifications only + elif body["Type"] == "Notification": + msg = json.loads(body["Message"]) + + # check if msg contains notificationType + if "notificationType" not in msg: + return + + recepients = msg["bounce"]["bouncedRecipients"] + + for recipient in recepients: + blockEmail = BlockedEmail(email=recipient["emailAddress"], reason=msg["notificationType"], severity=msg["bounce"]["bounceType"]) + self.db.add(blockEmail) + self.db.commit() + + return "OK" \ No newline at end of file diff --git a/services/emailService.py b/services/emailService.py new file mode 100644 index 0000000..28eedc6 --- /dev/null +++ b/services/emailService.py @@ -0,0 +1,103 @@ +import dotenv +import json + +dotenv.load_dotenv() +import os +import boto3 +from logging import getLogger + +logger = getLogger(__name__) + +class EmailService: + def __init__(self): + self.client = boto3.client( + "ses", + region_name=os.getenv("AWS_REGION"), + aws_access_key_id=os.getenv("AWS_ACCESS_KEY"), + aws_secret_access_key=os.getenv("AWS_SECRET_KEY"), + ) + self.senderEmail = os.getenv("AWS_SENDER_EMAIL") + + def createTemplate(self): + """Create or update email templates""" + try: + otp_template = { + "TemplateName": "sendOTP", + "SubjectPart": "Your OTP for login is {{otp}}", + "HtmlPart": """ +

Dear User,

+

Thank you for using our service. To complete your authentication, please use the following one-time password (OTP):

+

OTP: {{otp}}

+

This OTP is valid for 15 minutes. Do not share this OTP with anyone for security reasons. If you did not request this OTP, please ignore this email.

+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear User, Thank you for using our service. To complete your authentication, please use the following one-time password (OTP): OTP: {{otp}} This OTP is valid for 15 minutes. Do not share this OTP with anyone for security reasons. If you did not request this OTP, please ignore this email. Thank you, Team 24x7 AIHR" + } + + new_clinic_template = { + "TemplateName": "newClinic", + "SubjectPart": "New Clinic Added", + "HtmlPart": """ +

Dear Admin,

+

A new clinic has been added to the system.

+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear Admin, A new clinic has been added to the system. Thank you, Team 24x7 AIHR" + } + + reject_clinic_template = { + "TemplateName": "rejectClinic", + "SubjectPart": "Clinic Rejected", + "HtmlPart": """ +

Dear User,

+

Your clinic {{name}} has been rejected.

+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear User, Your clinic {{name}} has been rejected. Thank you, Team 24x7 AIHR" + } + + apporve_clinic_template = { + "TemplateName": "apporveClinic", + "SubjectPart": "Clinic Approved", + "HtmlPart": """ +

Dear User,

+

Congratulations! Your clinic {{name}} has been approved.

+

Thank you,
Team 24x7 AIHR

+ """, + "TextPart": "Dear User, Congratulations! Your clinic {{name}} has been approved. Thank you, Team 24x7 AIHR" + } + + self.client.create_template(Template=otp_template) + self.client.create_template(Template=new_clinic_template) + self.client.create_template(Template=reject_clinic_template) + self.client.create_template(Template=apporve_clinic_template) + + except Exception as e: + logger.error(f"Failed to create template: {e}") + raise Exception("Failed to create template") + + def send_email(self, template_name: str, to_address: str, template_data: dict): + """Send an email using a template""" + try: + response = self.client.send_templated_email( + Source=self.senderEmail, + Destination={ + 'ToAddresses': [to_address] + }, + Template=template_name, + TemplateData=json.dumps(template_data), + ReplyToAddresses=[self.senderEmail] + ) + logger.info(f"Email sent to {to_address} successfully. MessageId: {response['MessageId']}") + return response + except Exception as e: + logger.error(f"Error sending email to {to_address}: {str(e)}") + raise + + def send_otp_email(self, email: str, otp: str): + """Send OTP email""" + return self.send_email( + template_name="sendOTP", + to_address=email, + template_data={"otp": otp, "email": email} + ) \ No newline at end of file diff --git a/templates/__init.py b/templates/__init.py new file mode 100644 index 0000000..e69de29 diff --git a/templates/htmlTemplatest.py b/templates/htmlTemplatest.py new file mode 100644 index 0000000..955b748 --- /dev/null +++ b/templates/htmlTemplatest.py @@ -0,0 +1,153 @@ +otpTemplate = { + "TemplateName": "sendOTP", + "SubjectPart": "Your OTP for login is {{otp}}", + "HtmlPart": """ + + + + + One-Time Passcode + + + + +
+
+ +

One-Time Passcode

+
+
+

Dear User,

+

Thank you for your account verification request. Please use the following one-time passcode to complete the + authentication process:

+ +
+
{{otp}}
+
+ +
+ Security Notice: For your security, please do not share this code with anyone. Team 24x7 AIHR + representatives will never ask for your OTP. +
+ +

If you did not request this verification, please disregard this message and consider changing your password. +

+ +
+ Regards,
+ Team 24x7 AIHR +
+
+ +
+ + + + """, + "TextPart": "Dear User, Thank you for using our service. To complete your authentication, please use the following one-time password (OTP): OTP: {{otp}} This OTP is valid for 15 minutes. Do not share this OTP with anyone for security reasons. If you did not request this OTP, please ignore this email. Thank you, Team 24x7 AIHR" + } \ No newline at end of file