feat: sns webhook
feat: text content type handler feat: email blocking table
This commit is contained in:
parent
30f51618fe
commit
205e423b56
|
|
@ -2,10 +2,12 @@ from fastapi import APIRouter, Depends, Security
|
||||||
from middleware.auth_dependency import auth_required
|
from middleware.auth_dependency import auth_required
|
||||||
from fastapi.security import HTTPBearer
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
|
from apis.endpoints import sns
|
||||||
|
|
||||||
# Import the security scheme
|
# Import the security scheme
|
||||||
bearer_scheme = HTTPBearer(scheme_name="Bearer Authentication")
|
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 = APIRouter()
|
||||||
# api_router.include_router(twilio.router, prefix="/twilio")
|
# 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(patients.router, prefix="/patients", tags=["patients"])
|
||||||
|
|
||||||
|
api_router.include_router(sns.router, prefix="/sns", tags=["sns"], include_in_schema=False)
|
||||||
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
admin.router,
|
admin.router,
|
||||||
prefix="/admin",
|
prefix="/admin",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
18
main.py
18
main.py
|
|
@ -1,9 +1,9 @@
|
||||||
import dotenv
|
import dotenv
|
||||||
from fastapi import FastAPI, Security
|
from fastapi import FastAPI
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
import logging
|
import logging
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
from fastapi.security import HTTPBearer
|
||||||
|
|
||||||
# db
|
# db
|
||||||
from database import Base, engine
|
from database import Base, engine
|
||||||
|
|
@ -13,6 +13,7 @@ from apis import api_router
|
||||||
|
|
||||||
# middleware
|
# middleware
|
||||||
from middleware.ErrorHandlerMiddleware import ErrorHandlerMiddleware, configure_exception_handlers
|
from middleware.ErrorHandlerMiddleware import ErrorHandlerMiddleware, configure_exception_handlers
|
||||||
|
from middleware.CustomRequestTypeMiddleware import TextPlainMiddleware
|
||||||
|
|
||||||
dotenv.load_dotenv()
|
dotenv.load_dotenv()
|
||||||
|
|
||||||
|
|
@ -45,16 +46,6 @@ app = FastAPI(
|
||||||
title="Twillio Voice API",
|
title="Twillio Voice API",
|
||||||
description="API for Twillio Voice application",
|
description="API for Twillio Voice application",
|
||||||
version="1.0.0",
|
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}
|
swagger_ui_parameters={"defaultModelsExpandDepth": -1}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -67,6 +58,9 @@ app.add_middleware(
|
||||||
allow_headers=["*"], # Allows all headers
|
allow_headers=["*"], # Allows all headers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Custom request type middleware
|
||||||
|
app.add_middleware(TextPlainMiddleware)
|
||||||
|
|
||||||
# Error handler middleware
|
# Error handler middleware
|
||||||
app.add_middleware(ErrorHandlerMiddleware)
|
app.add_middleware(ErrorHandlerMiddleware)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
@ -10,6 +10,7 @@ from .ClinicDoctors import ClinicDoctors
|
||||||
from .Notifications import Notifications
|
from .Notifications import Notifications
|
||||||
from .CallTranscripts import CallTranscripts
|
from .CallTranscripts import CallTranscripts
|
||||||
from .Fcm import Fcm
|
from .Fcm import Fcm
|
||||||
|
from .BlockedEmail import BlockedEmail
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Users",
|
"Users",
|
||||||
|
|
@ -24,4 +25,5 @@ __all__ = [
|
||||||
"Notifications",
|
"Notifications",
|
||||||
"CallTranscripts",
|
"CallTranscripts",
|
||||||
"Fcm",
|
"Fcm",
|
||||||
|
"BlockedEmail",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@ from typing import List, Optional
|
||||||
from pydantic import BaseModel, EmailStr
|
from pydantic import BaseModel, EmailStr
|
||||||
from enums.enums import AppointmentStatus, ClinicDoctorStatus, ClinicDoctorType, ClinicUserRoles, UserType, Integration
|
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):
|
class AuthBase(BaseModel):
|
||||||
email: EmailStr
|
email: EmailStr
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,22 @@
|
||||||
from database import get_db
|
import json
|
||||||
|
import urllib.request
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
from services.jwtService import create_jwt_token
|
from services.jwtService import create_jwt_token
|
||||||
from services.userServices import UserServices
|
from services.userServices import UserServices
|
||||||
|
from models import BlockedEmail
|
||||||
from utils.password_utils import verify_password
|
from utils.password_utils import verify_password
|
||||||
from schemas.CreateSchemas import UserCreate
|
from schemas.CreateSchemas import UserCreate
|
||||||
from schemas.BaseSchemas import AuthBase
|
from schemas.BaseSchemas import AuthBase
|
||||||
from exceptions.unauthorized_exception import UnauthorizedException
|
from exceptions.unauthorized_exception import UnauthorizedException
|
||||||
|
|
||||||
|
from database import get_db
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.user_service = UserServices()
|
self.user_service = UserServices()
|
||||||
|
self.db = next(get_db())
|
||||||
|
|
||||||
def login(self, data: AuthBase) -> str:
|
def login(self, data: AuthBase) -> str:
|
||||||
|
|
||||||
|
|
@ -40,3 +48,29 @@ class AuthService:
|
||||||
}
|
}
|
||||||
token = create_jwt_token(user)
|
token = create_jwt_token(user)
|
||||||
return token
|
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"
|
||||||
|
|
@ -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": """
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Thank you for using our service. To complete your authentication, please use the following one-time password (OTP):</p>
|
||||||
|
<p>OTP: {{otp}}</p>
|
||||||
|
<p>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.</p>
|
||||||
|
<p>Thank you,<br/>Team 24x7 AIHR</p>
|
||||||
|
""",
|
||||||
|
"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": """
|
||||||
|
<p>Dear Admin,</p>
|
||||||
|
<p>A new clinic has been added to the system.</p>
|
||||||
|
<p>Thank you,<br/>Team 24x7 AIHR</p>
|
||||||
|
""",
|
||||||
|
"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": """
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Your clinic {{name}} has been rejected.</p>
|
||||||
|
<p>Thank you,<br/>Team 24x7 AIHR</p>
|
||||||
|
""",
|
||||||
|
"TextPart": "Dear User, Your clinic {{name}} has been rejected. Thank you, Team 24x7 AIHR"
|
||||||
|
}
|
||||||
|
|
||||||
|
apporve_clinic_template = {
|
||||||
|
"TemplateName": "apporveClinic",
|
||||||
|
"SubjectPart": "Clinic Approved",
|
||||||
|
"HtmlPart": """
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Congratulations! Your clinic {{name}} has been approved.</p>
|
||||||
|
<p>Thank you,<br/>Team 24x7 AIHR</p>
|
||||||
|
""",
|
||||||
|
"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}
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,153 @@
|
||||||
|
otpTemplate = {
|
||||||
|
"TemplateName": "sendOTP",
|
||||||
|
"SubjectPart": "Your OTP for login is {{otp}}",
|
||||||
|
"HtmlPart": """
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<title>One-Time Passcode</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background-color: #f4f4f5;
|
||||||
|
color: #333333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 30px auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: rgb(70, 45, 24);
|
||||||
|
color: #ffffff;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
height: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
max-height: 40px;
|
||||||
|
position: absolute;
|
||||||
|
left: 20px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: normal;
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 20px 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-code {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
letter-spacing: 5px;
|
||||||
|
margin: 25px 0;
|
||||||
|
color: rgb(70, 45, 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.otp-box {
|
||||||
|
background: #f9fafb;
|
||||||
|
border-left: 4px solid rgb(70, 45, 24);
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.security-notice {
|
||||||
|
font-size: 13px;
|
||||||
|
background-color: #fef9c3;
|
||||||
|
border-left: 4px solid #eab308;
|
||||||
|
padding: 12px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closing {
|
||||||
|
margin-top: 25px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding: 15px 30px;
|
||||||
|
background: #f4f4f5;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666666;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 480px) {
|
||||||
|
.content {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header,
|
||||||
|
.footer {
|
||||||
|
padding: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<img
|
||||||
|
src="https://firebasestorage.googleapis.com/v0/b/mansupport-australia.firebasestorage.app/o/Frame%20427320968.png?alt=media&token=9651f57d-aafd-450d-a0b0-1898504f5aac"
|
||||||
|
alt="Company Logo" class="logo" />
|
||||||
|
<h1>One-Time Passcode</h1>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<p>Dear User,</p>
|
||||||
|
<p>Thank you for your account verification request. Please use the following one-time passcode to complete the
|
||||||
|
authentication process:</p>
|
||||||
|
|
||||||
|
<div class="otp-box">
|
||||||
|
<div class="otp-code">{{otp}}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="security-notice">
|
||||||
|
<strong>Security Notice:</strong> For your security, please do not share this code with anyone. Team 24x7 AIHR
|
||||||
|
representatives will never ask for your OTP.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>If you did not request this verification, please disregard this message and consider changing your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="closing">
|
||||||
|
Regards,<br />
|
||||||
|
Team 24x7 AIHR
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">
|
||||||
|
This is a system-generated email. Please do not reply.<br />
|
||||||
|
© 2025 Team 24x7 AIHR Pty Ltd. All rights reserved.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
""",
|
||||||
|
"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"
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue