Skip to content

Commit 6b0542f

Browse files
authored
Reapply "SES integration (#23)" (#24) (#25)
This reverts commit 1f2d2af. Reapply "SES integration (#23)"
1 parent 1f2d2af commit 6b0542f

File tree

10 files changed

+138
-35
lines changed

10 files changed

+138
-35
lines changed

app/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,5 +49,9 @@ class Config(BaseSettings):
4949
DESCRIPTION: str = SWAGGER_APP_DESCRIPTION
5050
PORT: int = cast(int, os.getenv("PORT", "8000"))
5151

52+
AWS_SECRET_KEY: str = cast(str, os.getenv("AWS_SECRET_KEY"))
53+
AWS_ACCESS_KEY: str = cast(str, os.getenv("AWS_ACCESS_KEY"))
54+
AWS_REGION: str = cast(str, os.getenv("AWS_REGION"))
55+
AWS_EMAIL: str = cast(str, os.getenv("AWS_EMAIL"))
5256

5357
config = Config()

app/routers/email_router.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,16 @@
22
Email router for creating and controlling the endpoint.
33
"""
44

5+
from typing import Dict, Union
6+
57
from fastapi import APIRouter, Depends, HTTPException, status, Request
68
from fastapi.templating import Jinja2Templates
9+
710
from app.services.email_service import EmailService
8-
from app.schemas.email import SendEmailRequestBody, SendEmailResponseBody
11+
from app.schemas.email import SendEmailRequestBody, SendEmailResponseBody, EmailProvider
912
from app.utils import (
1013
EmailSender,
14+
SESEmailSender,
1115
success_response,
1216
get_email_sender,
1317
require_authentication,
@@ -23,7 +27,9 @@
2327
async def send_email(
2428
request: Request,
2529
email_details: SendEmailRequestBody,
26-
email_sender: EmailSender = Depends(get_email_sender),
30+
email_sender: Dict[EmailProvider, Union[EmailSender, SESEmailSender]] = Depends(
31+
get_email_sender
32+
),
2733
):
2834
"""
2935
Sends an email to the recipient using provided details.

app/schemas/email.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@
33
"""
44

55
from typing import Optional, Dict, List
6+
from enum import Enum
67
from fastapi import HTTPException, status
78
from pydantic import BaseModel, EmailStr, field_validator, ValidationInfo
89
from app.config import config
910

1011

12+
class EmailProvider(str, Enum):
13+
GMAIL = "GMAIL"
14+
SES = "SES"
15+
16+
1117
class SendEmailRequestBody(BaseModel):
1218
"""
1319
Request body for sending emails.
@@ -28,6 +34,7 @@ class SendEmailRequestBody(BaseModel):
2834
cc: Optional[List[EmailStr]] = None
2935
bcc: Optional[List[EmailStr]] = None
3036
self: bool = False
37+
provider: EmailProvider = EmailProvider.SES
3138

3239
@field_validator("body")
3340
@classmethod

app/services/email_service.py

Lines changed: 38 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,45 +2,62 @@
22
Third party email service configuration for sending emails.
33
"""
44

5+
from typing import Dict, Union
6+
57
from smtplib import SMTPException, SMTPAuthenticationError, SMTPSenderRefused
6-
from app.utils import EmailSender
7-
from app.schemas.email import SendEmailRequestBody
8+
import botocore.exceptions
9+
10+
from app.utils import EmailSender, SESEmailSender
11+
from app.schemas.email import SendEmailRequestBody, EmailProvider
812

913

1014
class EmailService:
1115
"""
1216
Service layer for handling email sending logic.
1317
"""
1418

15-
def __init__(self, email_sender: EmailSender):
19+
def __init__(
20+
self, email_sender: Dict[EmailProvider, Union[EmailSender, SESEmailSender]]
21+
):
1622
self.email_sender = email_sender
1723

1824
def send_email(self, email_details: SendEmailRequestBody, body: str):
1925
"""
2026
Sends an email based on email details.
21-
2227
Args:
2328
email_details (SendEmailRequestBody): Details for the email.
2429
body (str): body of the email.
2530
2631
Returns:
27-
bool: True if no failure occured on sending email.
32+
dict: A response dictionary containing:
33+
- "success" (bool): Indicates whether the email was sent successfully.
34+
- "error": Error message if the email sending fails.
2835
"""
2936
try:
30-
result = self.email_sender.send_email(
31-
to_email=email_details.recipient,
32-
subject=email_details.subject,
33-
body=body,
34-
cc=email_details.cc,
35-
bcc=email_details.bcc,
36-
is_html=True,
37-
)
37+
result = self._send_email_by_provider(email_details, body)
3838
return result
39-
except SMTPAuthenticationError as exc:
40-
raise PermissionError(
41-
"Failed to authenticate with the SMTP server."
42-
) from exc
43-
except SMTPSenderRefused as exc:
44-
raise ValueError("Sender address refused by the SMTP server.") from exc
45-
except SMTPException as smtp_exc:
46-
raise ConnectionError(f"SMTP error occurred: {smtp_exc}") from smtp_exc
39+
except Exception as error:
40+
self._handle_email_error(error)
41+
42+
def _send_email_by_provider(self, email_details: SendEmailRequestBody, body: str):
43+
provider = email_details.provider
44+
sender = self.email_sender.get(provider)
45+
result = sender.send_email(
46+
to_email=email_details.recipient,
47+
subject=email_details.subject,
48+
body=body,
49+
cc=email_details.cc,
50+
bcc=email_details.bcc,
51+
is_html=True,
52+
)
53+
return result
54+
55+
def _handle_email_error(self, error: Exception):
56+
if isinstance(
57+
error, (SMTPAuthenticationError, botocore.exceptions.ClientError)
58+
):
59+
raise PermissionError(f"Authentication failed: {str(error)}") from error
60+
elif isinstance(error, SMTPSenderRefused):
61+
raise ValueError(f"Email address refused: {str(error)}") from error
62+
else:
63+
raise ConnectionError(f"Email sending failed: {str(error)}") from error

app/utils/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .email_sender import EmailSender
1+
from .email_sender import EmailSender, SESEmailSender
22
from .dependencies import require_authentication, get_email_sender
33
from .response import error_response, success_response
44
from .exceptions import custom_http_exception_handler, custom_general_exception_handler

app/utils/dependencies.py

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33
"""
44

55
from functools import wraps
6+
from typing import Dict, Union
67
from fastapi import HTTPException, Request
78
from app.config import config
8-
from .email_sender import EmailSender
9+
from app.schemas.email import EmailProvider
10+
from .email_sender import EmailSender, SESEmailSender
911

1012

1113
def require_authentication():
@@ -27,14 +29,23 @@ async def wrapper(request: Request, *args, **kwargs):
2729
return decorator
2830

2931

30-
def get_email_sender() -> EmailSender:
32+
def get_email_sender() -> Dict[EmailProvider, Union[EmailSender, SESEmailSender]]:
3133
"""
32-
Creates and returns an EmailSender object.
34+
Creates and returns a dictionary of email sender objects.
3335
This function is used as a dependency injection in the controller.
3436
"""
35-
return EmailSender(
37+
gmail_sender = EmailSender(
3638
smtp_server=config.EMAIL_HOST,
3739
smtp_port=config.EMAIL_PORT,
3840
username=config.EMAIL_ADDRESS,
3941
password=config.EMAIL_PASSWORD,
4042
)
43+
44+
ses_sender = SESEmailSender(
45+
aws_access_key=config.AWS_ACCESS_KEY,
46+
aws_secret_key=config.AWS_SECRET_KEY,
47+
aws_region=config.AWS_REGION,
48+
aws_email=config.AWS_EMAIL,
49+
)
50+
51+
return {EmailProvider.GMAIL: gmail_sender, EmailProvider.SES: ses_sender}

app/utils/email_sender.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"""
44

55
import smtplib
6+
import boto3
67
from email.message import EmailMessage
78
from typing import List
89

@@ -48,7 +49,7 @@ def send_email(
4849
cc: List[EmailStr],
4950
bcc: List[EmailStr],
5051
is_html: bool = False,
51-
) -> bool:
52+
) -> dict:
5253
"""
5354
Sends an email using the configured SMTP settings.
5455
@@ -58,9 +59,10 @@ def send_email(
5859
body (str): The body of the email, which can be in plain text or HTML.
5960
is_html (bool, optional): Specifies whether the email body is HTML content.
6061
Defaults to False.
61-
6262
Returns:
63-
dict: A response dictionary containing the success of the email send action.
63+
dict: A response dictionary containing:
64+
- "success" (bool): Indicates whether the email was sent successfully.
65+
- "error": Error message if the email sending fails.
6466
6567
Raises:
6668
Exception: If there is an error during the email-sending process,
@@ -82,5 +84,56 @@ def send_email(
8284
server.login(self.username, self.password)
8385
server.send_message(msg)
8486
return {"success": True}
85-
except Exception as e:
86-
raise e
87+
except Exception as error:
88+
raise ConnectionError(f"Email sending failed: {str(error)}") from error
89+
90+
91+
class SESEmailSender:
92+
"""
93+
A helper class for sending emails using AWS SES.
94+
95+
This class provides methods for sending emails through Amazon Simple Email Service (SES).
96+
"""
97+
98+
def __init__(
99+
self, aws_access_key: str, aws_secret_key: str, aws_region: str, aws_email: str
100+
):
101+
self.aws_access_key = aws_access_key
102+
self.aws_secret_key = aws_secret_key
103+
self.aws_region = aws_region
104+
self.aws_email = aws_email
105+
106+
def send_email(
107+
self,
108+
to_email: str,
109+
subject: str,
110+
body: str,
111+
cc: List[EmailStr],
112+
bcc: List[EmailStr],
113+
is_html: bool = False,
114+
) -> dict:
115+
try:
116+
ses_client = boto3.client(
117+
"ses",
118+
region_name=self.aws_region,
119+
aws_access_key_id=self.aws_access_key,
120+
aws_secret_access_key=self.aws_secret_key,
121+
)
122+
message = {"Subject": {"Data": subject}, "Body": {}}
123+
if is_html:
124+
message["Body"]["Html"] = {"Data": body}
125+
else:
126+
message["Body"]["Text"] = {"Data": body}
127+
destination = {
128+
"ToAddresses": [to_email] if to_email else [],
129+
}
130+
if cc:
131+
destination["CcAddresses"] = cc
132+
if bcc:
133+
destination["BccAddresses"] = bcc
134+
response = ses_client.send_email(
135+
Source=self.aws_email, Destination=destination, Message=message
136+
)
137+
return {"success": True, "message_id": response["MessageId"]}
138+
except Exception as error:
139+
raise ConnectionError(f"Email sending failed: {str(error)}") from error

env_example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@ EMAIL_PASSWORD=somepassword
55
APP_SECRET=appsectsdjsnd
66
ENV=development
77
ORIGINS=https://abc.com,https://kbc.com
8-
PORT=5000
8+
PORT=5000
9+
AWS_SECRET_KEY=somesecretkey
10+
AWS_ACCESS_KEY=someaccesskey
11+
AWS_REGION=someregion
12+
AWS_EMAIL=awsverifiedemail

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,4 @@ typing_extensions==4.12.2
3535
uvicorn==0.32.0
3636
watchfiles==0.24.0
3737
websockets==13.1
38+
boto3>=1.37.27

run.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@
1313
if __name__ == "__main__":
1414
# uvicorn.run("app.main:app", port=config.PORT, reload=True)
1515
# --> run locally with auto reload on change
16-
uvicorn.run(app, port=config.PORT) # to run on vercel
16+
uvicorn.run(app, port=config.PORT) # to run on vercel

0 commit comments

Comments
 (0)