Skip to content

Commit

Permalink
✨ generating emails for account verification and reset password (#81)
Browse files Browse the repository at this point in the history
* 🎨 refactored project name to config

* ✨ added smtp variables to config

* ✨ added email template for reset password

* ✨ added emails and jinja2

* upgraded uvicorn version because of logging bug - encode/uvicorn#1285

* 🎨 refactored utils folder structure

* ✨ handling forget password bt sending an email

* 💄 cosmetic

* 🔥 removed unused code

* ✨ handling account verification by sending an email

* 🔥 removed unused parameters

* added logs

* renamed function

* import utils from its __init__.py

* added reset_password_token_lifetime_seconds and verification_token_secret to config

* added the reset password and user verification lifetime to the email templates

* added validator for EMAILS_FROM_NAME

* modified docker-compose env variables

* added env variables section in README.md

* fixed mypy

* fixed bandit

* added required env variables to ci
  • Loading branch information
eladgunders committed May 13, 2023
1 parent 48376eb commit 07d1fa0
Show file tree
Hide file tree
Showing 14 changed files with 228 additions and 9 deletions.
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ env:
POSTGRES_PASSWORD: password
CORS_ORIGINS: http://127.0.0.1:8000
JWT_SECRET_KEY: secret
SMTP_HOST: smtp.gmail.com
SMTP_PORT: 587
SMTP_USER: random.user@gmail.com
SMTP_PASSWORD: random.password
EMAILS_FROM_EMAIL: random.user@gmail.com
FRONT_END_BASE_URL: http://localhost:3000
PYTHONPATH: ./todos

jobs:
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ Clone the repository and navigate to its directory:
$ git clone https://github.com/eladgunders/fastapi-todos.git
$ cd fastapi-todos

### Setting up environment variables ⚙️

To properly configure the application, you'll need to define the following environment variables in the ```web-variables.env``` file:

- ```CORS_ORIGINS```: a comma-separated list of allowed origins for Cross-Origin Resource Sharing (CORS).
- ```FRONT_END_BASE_URL```: the base URL of your frontend application.
- ```SMTP_HOST```: the hostname or IP address of the SMTP server that will be used for sending emails.
- ```SMTP_PORT```: the port number of the SMTP server. Usually, this is 587.
- ```SMTP_USER```: the username for authenticating with the SMTP server.
- ```SMTP_PASSWORD```: the password for authenticating with the SMTP server.
- ```EMAILS_FROM_EMAIL```: the email address that will appear as the sender of all system-generated emails.
- ```EMAILS_FROM_NAME```: the name that will appear as the sender of all system-generated emails.

### Running the application with Docker Compose 🐳

To run the application locally, you will need to have [Docker](https://docs.docker.com/get-docker/)
Expand Down
3 changes: 3 additions & 0 deletions db-variables.env
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POSTGRES_DB=todos
POSTGRES_USER=username
POSTGRES_PASSWORD=password
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ services:
web:
env_file:
- ./web-variables.env
- ./db-variables.env
build: .
ports:
- 8000:8000
Expand All @@ -12,7 +13,7 @@ services:

db:
env_file:
- ./web-variables.env
- ./db-variables.env
image: postgres:13-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
Expand Down
6 changes: 4 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ fastapi-users[sqlalchemy]==10.1.5
fastapi-users-db-sqlalchemy==4.0.3
starlette==0.20.4
SQLAlchemy==1.4.41
uvicorn==0.18.3
uvicorn==0.22.0
asyncpg==0.26.0
alembic==1.5.8
alembic==1.5.8
emails==0.5.15
jinja2==3.1.2
34 changes: 33 additions & 1 deletion todos/app/core/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
from functools import lru_cache
from typing import Any, Optional

from pydantic import BaseSettings, PostgresDsn, AnyHttpUrl, validator, SecretStr
from pydantic import BaseSettings, PostgresDsn, AnyHttpUrl, validator, SecretStr, EmailStr


class Settings(BaseSettings):
PROJECT_NAME: str = 'Todos API'
API_V1_STR: str = '/api/v1'
JWT_SECRET_KEY: SecretStr

Expand Down Expand Up @@ -32,6 +33,37 @@ def assemble_db_connection(cls, _: str, values: dict[str, Any]) -> str:
path=f'/{values.get("POSTGRES_DB")}',
)

SMTP_TLS: bool = True
SMTP_HOST: Optional[str] = None
SMTP_PORT: Optional[int] = None
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[SecretStr] = None
EMAILS_FROM_EMAIL: Optional[EmailStr] = None
EMAILS_FROM_NAME: Optional[str] = None

@validator('EMAILS_FROM_NAME')
def get_project_name(cls, v: Optional[str], values: dict[str, Any]) -> str:
if not v:
return values['PROJECT_NAME']
return v

EMAIL_TEMPLATES_DIR: str = './todos/app/email-templates'
EMAILS_ENABLED: bool = False

@validator('EMAILS_ENABLED', pre=True)
def get_emails_enabled(cls, _: bool, values: dict[str, Any]) -> bool:
return all([
values.get('SMTP_HOST'),
values.get('SMTP_PORT'),
values.get('EMAILS_FROM_EMAIL')
])

# 60 seconds by 60 minutes (1 hour) and then by 12 (for 12 hours total)
RESET_PASSWORD_TOKEN_LIFETIME_SECONDS: int = 60 * 60 * 12
VERIFY_TOKEN_LIFETIME_SECONDS: int = 60 * 60 * 12

FRONT_END_BASE_URL: AnyHttpUrl

class Config:
env_file = '.env'
case_sensitive = True
Expand Down
26 changes: 26 additions & 0 deletions todos/app/email-templates/account_verification.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ project_name }} - Account Verification</title>
</head>
<body style="background-color: #f5f5f5; font-family: Arial, sans-serif;">
<div style="background-color: #fff; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="border-bottom: 1px solid #e1e1e1;">
<h1 style="font-size: 24px; color: #333; font-weight: 400;">{{ project_name }} - Account Verification</h1>
</div>
<div style="margin-top: 20px;">
<p style="font-size: 16px; color: #555;">Welcome to {{ project_name }}!</p>
<p style="font-size: 16px; color: #555;">We received a request for account verification for email <strong>{{ email }}</strong>.</p>
<p style="font-size: 16px; color: #555;">Please click the button below to verify your account:</p>
<div style="margin-top: 20px; text-align: center;">
<a href="{{ link }}" style="display: inline-block; background-color: #007bff; color: #fff; text-decoration: none; font-size: 16px; font-weight: 600; padding: 12px 30px; border-radius: 5px;">Verify Account</a>
</div>
</div>
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 20px;">
<p style="font-size: 14px; color: #555;">The account verification button will expire in {{ expire_hours }} hours</p>
<p style="font-size: 14px; color: #555;">If you did not ask for an account verification on {{ project_name }}, you can disregard this email.</p>
</div>
</div>
</body>
</html>
25 changes: 25 additions & 0 deletions todos/app/email-templates/reset_password.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{ project_name }} - Password Recovery</title>
</head>
<body style="background-color: #f5f5f5; font-family: Arial, sans-serif;">
<div style="background-color: #fff; max-width: 600px; margin: 0 auto; padding: 20px;">
<div style="border-bottom: 1px solid #e1e1e1;">
<h1 style="font-size: 24px; color: #333; font-weight: 400;">{{ project_name }} - Password Recovery</h1>
</div>
<div style="margin-top: 20px;">
<p style="font-size: 16px; color: #555;">We received a request to recover the password for email <strong>{{ email }}</strong>.</p>
<p style="font-size: 16px; color: #555;">Reset your password by clicking the button below:</p>
<div style="margin-top: 20px; text-align: center;">
<a href="{{ link }}" style="display: inline-block; background-color: #007bff; color: #fff; text-decoration: none; font-size: 16px; font-weight: 600; padding: 12px 30px; border-radius: 5px;">Reset Password</a>
</div>
</div>
<div style="border-top: 1px solid #e1e1e1; margin-top: 20px; padding-top: 20px;">
<p style="font-size: 14px; color: #555;">The reset password button will expire in {{ expire_hours }} hours</p>
<p style="font-size: 14px; color: #555;">If you didn't request a password recovery, you can disregard this email.</p>
</div>
</div>
</body>
</html>
2 changes: 1 addition & 1 deletion todos/app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
config = get_config()

app = FastAPI(
title='TODOS API',
title=config.PROJECT_NAME,
openapi_url=f'{config.API_V1_STR}/openapi.json'
)

Expand Down
27 changes: 27 additions & 0 deletions todos/app/users/manager.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,42 @@
import uuid
from typing import Optional
import logging

from pydantic import SecretStr
from fastapi import Request
from fastapi_users import BaseUserManager, UUIDIDMixin

from app.core.config import get_config
from app.models.tables import User
from app.utils import send_reset_password_email, send_account_verification_email


config = get_config()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
reset_password_token_secret: SecretStr = config.JWT_SECRET_KEY
reset_password_token_lifetime_seconds: int = config.RESET_PASSWORD_TOKEN_LIFETIME_SECONDS
verification_token_secret: SecretStr = config.JWT_SECRET_KEY
verification_token_lifetime_seconds: int = config.VERIFY_TOKEN_LIFETIME_SECONDS

async def on_after_forgot_password(
self,
user: User,
token: str,
request: Optional[Request] = None
) -> None:
send_reset_password_email(email_to=user.email, token=token)
logger.info('sent reset password email to %s', user.email)

async def on_after_request_verify(
self,
user: User,
token: str,
request: Optional[Request] = None
) -> None:
send_account_verification_email(email_to=user.email, token=token)
logger.info('sent account verification email to %s', user.email)
2 changes: 2 additions & 0 deletions todos/app/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .emails import send_reset_password_email, send_account_verification_email
from .exceptions import exception_handler
78 changes: 78 additions & 0 deletions todos/app/utils/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from typing import Any, Optional
import logging

from emails import Message
from emails.template import JinjaTemplate

from app.core.config import get_config


config = get_config()

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)


def send_email(
*,
email_to: str,
environment: Optional[dict[str, Any]],
subject_template: str = "",
html_template: str = "",
) -> None:
if not config.EMAILS_ENABLED:
raise RuntimeError('no configuration provided for email variables')
if not environment:
environment = {}
message = Message(
subject=JinjaTemplate(subject_template),
html=JinjaTemplate(html_template),
mail_from=(config.EMAILS_FROM_NAME, config.EMAILS_FROM_EMAIL),
)
smtp_options = {'host': config.SMTP_HOST, 'port': config.SMTP_PORT}
if config.SMTP_TLS:
smtp_options['tls'] = True
if config.SMTP_USER:
smtp_options['user'] = config.SMTP_USER
if config.SMTP_PASSWORD:
smtp_options['password'] = config.SMTP_PASSWORD.get_secret_value()
res = message.send(to=email_to, render=environment, smtp=smtp_options)
logger.info('send email result %s', res)


def send_reset_password_email(*, email_to: str, token: str) -> None:
subject = f'{config.PROJECT_NAME} - Password recovery for email {email_to}'
with open(f'{config.EMAIL_TEMPLATES_DIR}/reset_password.html', 'r', encoding='utf-8') as f:
template_str = f.read()
link = f'{config.FRONT_END_BASE_URL}/reset-password?token={token}'
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
'project_name': config.PROJECT_NAME,
'email': email_to,
'link': link,
# dividing by 3600 to get the number of hours from the number of seconds
'expire_hours': config.RESET_PASSWORD_TOKEN_LIFETIME_SECONDS / 3600,
}
)


def send_account_verification_email(*, email_to: str, token: str) -> None:
subject = f'{config.PROJECT_NAME} - Account verification for email {email_to}'
with open(f'{config.EMAIL_TEMPLATES_DIR}/account_verification.html', 'r', encoding='utf-8') as f:
template_str = f.read()
link = f'{config.FRONT_END_BASE_URL}/verify-account?token={token}'
send_email(
email_to=email_to,
subject_template=subject,
html_template=template_str,
environment={
'project_name': config.PROJECT_NAME,
'email': email_to,
'link': link,
# dividing by 3600 to get the number of hours from the number of seconds
'expire_hours': config.VERIFY_TOKEN_LIFETIME_SECONDS / 3600,
}
)
File renamed without changes.
12 changes: 8 additions & 4 deletions web-variables.env
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
JWT_SECRET_KEY=SECRET
CORS_ORIGINS=http://localhost:3000;http://127.0.0.1:8000
POSTGRES_DB=todos
POSTGRES_HOST=db:5432
POSTGRES_USER=username
POSTGRES_PASSWORD=password
CORS_ORIGINS=http://localhost:3000;http://127.0.0.1:8000
FRONT_END_BASE_URL=http://localhost:3000
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
EMAILS_FROM_EMAIL=
EMAILS_FROM_NAME=

0 comments on commit 07d1fa0

Please sign in to comment.