diff --git a/py/core/base/providers/email.py b/py/core/base/providers/email.py index 3aca8f5d8..14c7fdef1 100644 --- a/py/core/base/providers/email.py +++ b/py/core/base/providers/email.py @@ -1,7 +1,8 @@ # email_provider.py import logging from abc import ABC, abstractmethod -from typing import Optional +from typing import Optional, Dict +import os from .base import Provider, ProviderConfig @@ -13,27 +14,22 @@ class EmailConfig(ProviderConfig): smtp_password: Optional[str] = None from_email: Optional[str] = None use_tls: Optional[bool] = True - + sendgrid_api_key: Optional[str] = None + verify_email_template_id: Optional[str] = None + reset_password_template_id: Optional[str] = None + frontend_url: Optional[str] = None @property def supported_providers(self) -> list[str]: return [ "smtp", "console", + "sendgrid", ] # Could add more providers like AWS SES, SendGrid etc. def validate_config(self) -> None: - pass - # if self.provider == "smtp": - # if not all( - # [ - # self.smtp_server, - # self.smtp_port, - # self.smtp_username, - # self.smtp_password, - # self.from_email, - # ] - # ): - # raise ValueError("SMTP configuration is incomplete") + if self.provider == "sendgrid": + if not (self.sendgrid_api_key or os.getenv("SENDGRID_API_KEY")): + raise ValueError("SendGrid API key is required when using SendGrid provider") logger = logging.getLogger(__name__) @@ -55,17 +51,27 @@ async def send_email( subject: str, body: str, html_body: Optional[str] = None, + *args, + **kwargs ) -> None: pass @abstractmethod async def send_verification_email( - self, to_email: str, verification_code: str + self, + to_email: str, + verification_code: str, + *args, + **kwargs ) -> None: pass @abstractmethod async def send_password_reset_email( - self, to_email: str, reset_token: str + self, + to_email: str, + reset_token: str, + *args, + **kwargs ) -> None: pass diff --git a/py/core/main/abstractions.py b/py/core/main/abstractions.py index 4a7b71f28..1dc6ef1a0 100644 --- a/py/core/main/abstractions.py +++ b/py/core/main/abstractions.py @@ -21,6 +21,7 @@ SqlitePersistentLoggingProvider, SupabaseAuthProvider, UnstructuredIngestionProvider, + SendGridEmailProvider, ) @@ -38,7 +39,7 @@ class R2RProviders(BaseModel): HatchetOrchestrationProvider, SimpleOrchestrationProvider ] logging: SqlitePersistentLoggingProvider - email: Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider] + email: Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider, SendGridEmailProvider] class Config: arbitrary_types_allowed = True diff --git a/py/core/main/assembly/factory.py b/py/core/main/assembly/factory.py index 8ba3efc76..2ea14e2b5 100644 --- a/py/core/main/assembly/factory.py +++ b/py/core/main/assembly/factory.py @@ -20,6 +20,8 @@ from core.pipes import GeneratorPipe, MultiSearchPipe, SearchPipe from core.providers.logger.r2r_logger import SqlitePersistentLoggingProvider +from core.providers.email.sendgrid import SendGridEmailProvider + from ..abstractions import R2RAgents, R2RPipelines, R2RPipes, R2RProviders from ..config import R2RConfig @@ -56,7 +58,7 @@ async def create_auth_provider( crypto_provider: BCryptProvider, database_provider: PostgresDBProvider, email_provider: Union[ - AsyncSMTPEmailProvider, ConsoleMockEmailProvider + AsyncSMTPEmailProvider, ConsoleMockEmailProvider, SendGridEmailProvider ], *args, **kwargs, @@ -235,7 +237,7 @@ def create_llm_provider( @staticmethod async def create_email_provider( email_config: Optional[EmailConfig] = None, *args, **kwargs - ) -> Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider]: + ) -> Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider, SendGridEmailProvider]: """Creates an email provider based on configuration.""" if not email_config: raise ValueError( @@ -246,6 +248,8 @@ async def create_email_provider( return AsyncSMTPEmailProvider(email_config) elif email_config.provider == "console_mock": return ConsoleMockEmailProvider(email_config) + elif email_config.provider == "sendgrid": + return SendGridEmailProvider(email_config) else: raise ValueError( f"Email provider {email_config.provider} not supported." @@ -259,7 +263,7 @@ async def create_providers( crypto_provider_override: Optional[BCryptProvider] = None, database_provider_override: Optional[PostgresDBProvider] = None, email_provider_override: Optional[ - Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider] + Union[AsyncSMTPEmailProvider, ConsoleMockEmailProvider, SendGridEmailProvider] ] = None, embedding_provider_override: Optional[ Union[ diff --git a/py/core/providers/__init__.py b/py/core/providers/__init__.py index a970f83eb..355a0eaab 100644 --- a/py/core/providers/__init__.py +++ b/py/core/providers/__init__.py @@ -1,7 +1,11 @@ from .auth import R2RAuthProvider, SupabaseAuthProvider from .crypto import BCryptConfig, BCryptProvider from .database import PostgresDBProvider -from .email import AsyncSMTPEmailProvider, ConsoleMockEmailProvider +from .email import ( + AsyncSMTPEmailProvider, + ConsoleMockEmailProvider, + SendGridEmailProvider, +) from .embeddings import ( LiteLLMEmbeddingProvider, OllamaEmbeddingProvider, @@ -41,6 +45,7 @@ # Email "AsyncSMTPEmailProvider", "ConsoleMockEmailProvider", + "SendGridEmailProvider", # Orchestration "HatchetOrchestrationProvider", "SimpleOrchestrationProvider", diff --git a/py/core/providers/auth/r2r_auth.py b/py/core/providers/auth/r2r_auth.py index f060591e5..66acf7469 100644 --- a/py/core/providers/auth/r2r_auth.py +++ b/py/core/providers/auth/r2r_auth.py @@ -147,7 +147,6 @@ async def register(self, email: str, password: str) -> UserResponse: ) if self.config.require_email_verification: - # Generate verification code and send email verification_code = ( self.crypto_provider.generate_verification_code() ) @@ -157,10 +156,12 @@ async def register(self, email: str, password: str) -> UserResponse: new_user.id, verification_code, expiry ) new_user.verification_code_expiry = expiry - # TODO - Integrate email provider(s) + # Safely get first name, defaulting to email if name is None + first_name = new_user.name.split(" ")[0] if new_user.name else email.split("@")[0] + await self.email_provider.send_verification_email( - new_user.email, verification_code + new_user.email, verification_code, {"first_name": first_name} ) else: expiry = datetime.now(timezone.utc) + timedelta(hours=366 * 10) @@ -307,8 +308,9 @@ async def request_password_reset(self, email: str) -> dict[str, str]: user.id, reset_token, expiry ) - # TODO: Integrate with email provider to send reset link - await self.email_provider.send_password_reset_email(email, reset_token) + # Safely get first name, defaulting to email if name is None + first_name = user.name.split(" ")[0] if user.name else email.split("@")[0] + await self.email_provider.send_password_reset_email(email, reset_token, {"first_name": first_name}) return {"message": "If the email exists, a reset link has been sent"} @@ -341,19 +343,6 @@ async def clean_expired_blacklisted_tokens(self): await self.database_provider.clean_expired_blacklisted_tokens() async def send_reset_email(self, email: str) -> dict: - """ - Generate a new verification code and send a reset email to the user. - Returns the verification code for testing/sandbox environments. - - Args: - email (str): The email address of the user - - Returns: - dict: Contains verification_code and message - - Raises: - R2RException: If user is not found - """ user = await self.database_provider.get_user_by_email(email) if not user: raise R2RException(status_code=404, message="User not found") @@ -369,9 +358,11 @@ async def send_reset_email(self, email: str) -> dict: expiry, ) + # Safely get first name, defaulting to email if name is None + first_name = user.name.split(" ")[0] if user.name else email.split("@")[0] # Send verification email await self.email_provider.send_verification_email( - email, verification_code + email, verification_code, {"first_name": first_name} ) return { diff --git a/py/core/providers/email/__init__.py b/py/core/providers/email/__init__.py index d70f65330..0755615e4 100644 --- a/py/core/providers/email/__init__.py +++ b/py/core/providers/email/__init__.py @@ -1,4 +1,9 @@ from .console_mock import ConsoleMockEmailProvider from .smtp import AsyncSMTPEmailProvider +from .sendgrid import SendGridEmailProvider -__all__ = ["ConsoleMockEmailProvider", "AsyncSMTPEmailProvider"] +__all__ = [ + "ConsoleMockEmailProvider", + "AsyncSMTPEmailProvider", + "SendGridEmailProvider", +] diff --git a/py/core/providers/email/console_mock.py b/py/core/providers/email/console_mock.py index 3bab24723..6f47f5e1b 100644 --- a/py/core/providers/email/console_mock.py +++ b/py/core/providers/email/console_mock.py @@ -15,6 +15,8 @@ async def send_email( subject: str, body: str, html_body: Optional[str] = None, + *args, + **kwargs ) -> None: logger.info( f""" @@ -28,7 +30,11 @@ async def send_email( ) async def send_verification_email( - self, to_email: str, verification_code: str + self, + to_email: str, + verification_code: str, + *args, + **kwargs ) -> None: logger.info( f""" @@ -42,7 +48,11 @@ async def send_verification_email( ) async def send_password_reset_email( - self, to_email: str, reset_token: str + self, + to_email: str, + reset_token: str, + *args, + **kwargs ) -> None: logger.info( f""" diff --git a/py/core/providers/email/sendgrid.py b/py/core/providers/email/sendgrid.py new file mode 100644 index 000000000..daa0f1526 --- /dev/null +++ b/py/core/providers/email/sendgrid.py @@ -0,0 +1,182 @@ +import logging +import os +from typing import Optional, Dict + +from sendgrid import SendGridAPIClient +from sendgrid.helpers.mail import Mail, Content + +from core.base import EmailConfig, EmailProvider + +logger = logging.getLogger(__name__) + + +class SendGridEmailProvider(EmailProvider): + """Email provider implementation using SendGrid API""" + + def __init__(self, config: EmailConfig): + super().__init__(config) + self.api_key = config.sendgrid_api_key or os.getenv("SENDGRID_API_KEY") + if not self.api_key or not isinstance(self.api_key, str): + raise ValueError("A valid SendGrid API key is required.") + + self.from_email = config.from_email or os.getenv("R2R_FROM_EMAIL") + if not self.from_email or not isinstance(self.from_email, str): + raise ValueError("A valid from email is required.") + self.frontend_url = config.frontend_url or os.getenv("R2R_FRONTEND_URL") + if not self.frontend_url or not isinstance(self.frontend_url, str): + raise ValueError("A valid frontend URL is required.") + self.verify_email_template_id = config.verify_email_template_id + self.reset_password_template_id = config.reset_password_template_id + self.client = SendGridAPIClient(api_key=self.api_key) + + async def send_email( + self, + to_email: str, + subject: Optional[str] = None, + body: Optional[str] = None, + html_body: Optional[str] = None, + template_id: Optional[str] = None, + dynamic_template_data: Optional[Dict] = None, + ) -> None: + try: + logger.info("Preparing SendGrid message...") + message = Mail( + from_email=self.from_email, + to_emails=to_email, + ) + + if template_id: + logger.info(f"Using dynamic template with ID: {template_id}") + message.template_id = template_id + message.dynamic_template_data = dynamic_template_data or {} + else: + if not subject: + raise ValueError("Subject is required when not using a template") + message.subject = subject + + # Add plain text content + message.add_content(Content("text/plain", body or "")) + + # Add HTML content if provided + if html_body: + message.add_content(Content("text/html", html_body)) + + # Send email + import asyncio + response = await asyncio.to_thread(self.client.send, message) + + if response.status_code >= 400: + raise RuntimeError(f"Failed to send email: {response.status_code}") + + if response.status_code == 202: + logger.info("Message sent successfully!") + else: + error_msg = f"Failed to send email. Status code: {response.status_code}, Body: {response.body}" + logger.error(error_msg) + raise RuntimeError(error_msg) + + except Exception as e: + error_msg = f"Failed to send email to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + async def send_verification_email( + self, + to_email: str, + verification_code: str, + dynamic_template_data: Optional[Dict] = None, + ) -> None: + try: + if self.verify_email_template_id: + # Use dynamic template + dynamic_data = { + "url": f"{self.frontend_url or self.config.app_url}/verify-email?token={verification_code}&email={to_email}", + } + + if dynamic_template_data: + dynamic_data.update(dynamic_template_data) + + await self.send_email( + to_email=to_email, + template_id=self.verify_email_template_id, + dynamic_template_data=dynamic_data, + ) + else: + # Fallback to default content + subject = "Please verify your email address" + body = f""" + Please verify your email address by entering the following code: + + Verification code: {verification_code} + + If you did not request this verification, please ignore this email. + """ + html_body = f""" +

Please verify your email address by entering the following code:

+

+ Verification code: {verification_code} +

+

If you did not request this verification, please ignore this email.

+ """ + + await self.send_email( + to_email=to_email, + subject=subject, + body=body, + html_body=html_body, + ) + except Exception as e: + error_msg = f"Failed to send email to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e + + + async def send_password_reset_email( + self, + to_email: str, + reset_token: str, + dynamic_template_data: Optional[Dict] = None, + ) -> None: + try: + if self.reset_password_template_id: + # Use dynamic template + dynamic_data = { + "url": f"{self.frontend_url or self.config.app_url}/reset-password?token={reset_token}", + } + + if dynamic_template_data: + dynamic_data.update(dynamic_template_data) + + await self.send_email( + to_email=to_email, + template_id=self.reset_password_template_id, + dynamic_template_data=dynamic_data, + ) + else: + # Fallback to default content + subject = "Password Reset Request" + body = f""" + You have requested to reset your password. + + Reset token: {reset_token} + + If you did not request a password reset, please ignore this email. + """ + html_body = f""" +

You have requested to reset your password.

+

+ Reset token: {reset_token} +

+

If you did not request a password reset, please ignore this email.

+ """ + + await self.send_email( + to_email=to_email, + subject=subject, + body=body, + html_body=html_body, + ) + except Exception as e: + error_msg = f"Failed to send email to {to_email}: {str(e)}" + logger.error(error_msg) + raise RuntimeError(error_msg) from e \ No newline at end of file diff --git a/py/core/providers/email/smtp.py b/py/core/providers/email/smtp.py index c45f5ff61..223b08066 100644 --- a/py/core/providers/email/smtp.py +++ b/py/core/providers/email/smtp.py @@ -74,6 +74,8 @@ async def send_email( subject: str, body: str, html_body: Optional[str] = None, + *args, + **kwargs ) -> None: msg = MIMEMultipart("alternative") msg["Subject"] = subject @@ -98,7 +100,11 @@ async def send_email( raise RuntimeError(error_msg) from e async def send_verification_email( - self, to_email: str, verification_code: str + self, + to_email: str, + verification_code: str, + *args, + **kwargs ) -> None: body = f""" Please verify your email address by entering the following code: @@ -124,7 +130,11 @@ async def send_verification_email( ) async def send_password_reset_email( - self, to_email: str, reset_token: str + self, + to_email: str, + reset_token: str, + *args, + **kwargs ) -> None: body = f""" You have requested to reset your password. diff --git a/py/poetry.lock b/py/poetry.lock index 6594e7d5b..a20b36d89 100644 --- a/py/poetry.lock +++ b/py/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "aiofiles" @@ -4319,6 +4319,17 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "python-http-client" +version = "3.3.7" +description = "HTTP REST client, simplified for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +files = [ + {file = "python_http_client-3.3.7-py3-none-any.whl", hash = "sha256:ad371d2bbedc6ea15c26179c6222a78bc9308d272435ddf1d5c84f068f249a36"}, + {file = "python_http_client-3.3.7.tar.gz", hash = "sha256:bf841ee45262747e00dec7ee9971dfb8c7d83083f5713596488d67739170cea0"}, +] + [[package]] name = "python-multipart" version = "0.0.9" @@ -4731,6 +4742,11 @@ files = [ {file = "scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f60021ec1574e56632be2a36b946f8143bf4e5e6af4a06d85281adc22938e0dd"}, {file = "scikit_learn-1.5.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:394397841449853c2290a32050382edaec3da89e35b3e03d6cc966aebc6a8ae6"}, {file = "scikit_learn-1.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:57cc1786cfd6bd118220a92ede80270132aa353647684efa385a74244a41e3b1"}, + {file = "scikit_learn-1.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9a702e2de732bbb20d3bad29ebd77fc05a6b427dc49964300340e4c9328b3f5"}, + {file = "scikit_learn-1.5.2-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:b0768ad641981f5d3a198430a1d31c3e044ed2e8a6f22166b4d546a5116d7908"}, + {file = "scikit_learn-1.5.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:178ddd0a5cb0044464fc1bfc4cca5b1833bfc7bb022d70b05db8530da4bb3dd3"}, + {file = "scikit_learn-1.5.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f7284ade780084d94505632241bf78c44ab3b6f1e8ccab3d2af58e0e950f9c12"}, + {file = "scikit_learn-1.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:b7b0f9a0b1040830d38c39b91b3a44e1b643f4b36e36567b80b7c6bd2202a27f"}, {file = "scikit_learn-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:757c7d514ddb00ae249832fe87100d9c73c6ea91423802872d9e74970a0e40b9"}, {file = "scikit_learn-1.5.2-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:52788f48b5d8bca5c0736c175fa6bdaab2ef00a8f536cda698db61bd89c551c1"}, {file = "scikit_learn-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:643964678f4b5fbdc95cbf8aec638acc7aa70f5f79ee2cdad1eec3df4ba6ead8"}, @@ -4817,6 +4833,21 @@ dev = ["flake8", "flit", "mypy", "pandas-stubs", "pre-commit", "pytest", "pytest docs = ["ipykernel", "nbconvert", "numpydoc", "pydata_sphinx_theme (==0.10.0rc2)", "pyyaml", "sphinx (<6.0.0)", "sphinx-copybutton", "sphinx-design", "sphinx-issues"] stats = ["scipy (>=1.7)", "statsmodels (>=0.12)"] +[[package]] +name = "sendgrid" +version = "6.11.0" +description = "Twilio SendGrid library for Python" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "sendgrid-6.11.0-py3-none-any.whl", hash = "sha256:43ecf5bb742ea5850c7cfe68f5e7d9948772352306d4e83e119899959538b884"}, + {file = "sendgrid-6.11.0.tar.gz", hash = "sha256:71424b2a97f5a034121ea3b2666c653ba0ed315982f0d57b7851c0c9503dc5ab"}, +] + +[package.dependencies] +python-http-client = ">=3.2.1" +starkbank-ecdsa = ">=2.0.1" + [[package]] name = "setuptools" version = "75.3.0" @@ -4990,6 +5021,16 @@ postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"] pymysql = ["pymysql"] sqlcipher = ["sqlcipher3_binary"] +[[package]] +name = "starkbank-ecdsa" +version = "2.2.0" +description = "A lightweight and fast pure python ECDSA library" +optional = false +python-versions = "*" +files = [ + {file = "starkbank-ecdsa-2.2.0.tar.gz", hash = "sha256:9399c3371b899d4a235b68a1ed7919d202fbf024bd2c863ae8ebdad343c2a63a"}, +] + [[package]] name = "starlette" version = "0.38.6" @@ -5177,6 +5218,7 @@ files = [ {file = "tiktoken-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:d8c2d0e5ba6453a290b86cd65fc51fedf247e1ba170191715b049dac1f628005"}, {file = "tiktoken-0.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d622d8011e6d6f239297efa42a2657043aaed06c4f68833550cac9e9bc723ef1"}, {file = "tiktoken-0.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2efaf6199717b4485031b4d6edb94075e4d79177a172f38dd934d911b588d54a"}, + {file = "tiktoken-0.8.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5637e425ce1fc49cf716d88df3092048359a4b3bbb7da762840426e937ada06d"}, {file = "tiktoken-0.8.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fb0e352d1dbe15aba082883058b3cce9e48d33101bdaac1eccf66424feb5b47"}, {file = "tiktoken-0.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:56edfefe896c8f10aba372ab5706b9e3558e78db39dd497c940b47bf228bc419"}, {file = "tiktoken-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:326624128590def898775b722ccc327e90b073714227175ea8febbc920ac0a99"}, @@ -5917,4 +5959,4 @@ ingestion-bundle = ["aiofiles", "aioshutil", "beautifulsoup4", "bs4", "markdown" [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "86935f6ca1f9822ea3fc3b1d28ae2e6414177851660799f6f10c30cfd1a0f169" +content-hash = "6e36e5905542dad3b4125f5cb2843e6053cc2aa405b6e89ffeafa4d33accfe62" diff --git a/py/pyproject.toml b/py/pyproject.toml index adecc21ce..b90fe2591 100644 --- a/py/pyproject.toml +++ b/py/pyproject.toml @@ -82,6 +82,7 @@ python-docx = { version = "^1.1.0", optional = true } aiosmtplib = "^3.0.2" types-aiofiles = "^24.1.0.20240626" aiohttp = "^3.10.10" +sendgrid = "^6.11.0" [tool.poetry.extras] core = [ diff --git a/py/tests/core/providers/email/test_email_providers.py b/py/tests/core/providers/email/test_email_providers.py new file mode 100644 index 000000000..d1176bb2e --- /dev/null +++ b/py/tests/core/providers/email/test_email_providers.py @@ -0,0 +1,72 @@ +import pytest +from core.base.providers.email import EmailConfig +from core.providers.email import SendGridEmailProvider + + +@pytest.fixture(scope="function") +def sendgrid_config(app_config): + return EmailConfig( + provider="sendgrid", + sendgrid_api_key="your_sendgrid_api_key", + from_email="support@example.com", # Ensure this email is verified in your SendGrid account + app=app_config + ) + +@pytest.fixture +def sendgrid_provider(sendgrid_config): + return SendGridEmailProvider(sendgrid_config) + +class TestSendGridEmailProvider: + @pytest.mark.asyncio + async def test_send_email_basic(self, sendgrid_provider): + await sendgrid_provider.send_email( + to_email="example@example.com", # Replace with your email address + subject="Test Email", + body="This is a test email sent from the test_send_email_basic test case." + ) + # If your send_email method returns a response, you can add assertions here + + @pytest.mark.asyncio + async def test_send_email_with_template(self, sendgrid_provider): + await sendgrid_provider.send_email( + to_email="example@example.com", # Replace with your email address + template_id="template_id", # Replace with your SendGrid template ID + dynamic_template_data={"first_name": "Example"} + ) + # Add assertions if applicable + + @pytest.mark.asyncio + async def test_send_verification_email(self, sendgrid_provider): + await sendgrid_provider.send_verification_email( + to_email="example@example.com", # Replace with your email address + verification_code="123456" + ) + # Add assertions if applicable + + @pytest.mark.asyncio + async def test_send_verification_email_with_template(self, sendgrid_provider): + await sendgrid_provider.send_verification_email( + to_email="example@example.com", # Replace with your email address + verification_code="123456", + ) + # Add assertions if applicable + + @pytest.mark.asyncio + async def test_send_verification_email_with_template_and_dynamic_data(self, sendgrid_provider): + await sendgrid_provider.send_verification_email( + to_email="example@example.com", # Replace with your email address + verification_code="123456", + dynamic_template_data={"name": "User"}, + frontend_url="http://localhost:3000/auth" + ) + # Add assertions if applicable + + @pytest.mark.asyncio + async def test_send_email_failure(self, sendgrid_provider): + # Intentionally use an invalid email to simulate a failure + with pytest.raises(RuntimeError): + await sendgrid_provider.send_email( + to_email="invalid-email-address", # Invalid email address + subject="Test Email", + body="This should fail." + )