Skip to content

Working email invitation and acceptance flow #123

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ DB_NAME=

# Resend
RESEND_API_KEY=
EMAIL_FROM=
1 change: 1 addition & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
echo "SECRET_KEY=$(openssl rand -base64 32)" >> _environment
echo "BASE_URL=http://localhost:8000" >> _environment
echo "RESEND_API_KEY=resend_api_key" >> _environment
echo "[email protected]" >> _environment

- name: Setup Graphviz
uses: ts-graphviz/setup-graphviz@v2
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
echo "SECRET_KEY=$(openssl rand -base64 32)" >> $GITHUB_ENV
echo "BASE_URL=http://localhost:8000" >> $GITHUB_ENV
echo "RESEND_API_KEY=resend_api_key" >> $GITHUB_ENV
echo "[email protected]" >> $GITHUB_ENV

- name: Verify environment variables
run: |
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,9 +145,13 @@ it into the .env file.

Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/)
account, verify a domain, get an API key, and paste the API key into the
.env file.
To use password recovery and other email features, register a
[Resend](https://resend.com/) account, verify a domain, get an API key,
and paste the API key and the email address you want to send emails from
into the .env file. Note that you will need to [verify a domain through
the Resend
dashboard](https://resend.com/docs/dashboard/domains/introduction) to
send emails from that domain.

### Start development database

Expand Down
2 changes: 1 addition & 1 deletion docs/installation.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t

Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file.

If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.)

Expand Down
194 changes: 190 additions & 4 deletions docs/static/documentation.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# FastAPI, Jinja2, PostgreSQL Webapp Template

![Screenshot of homepage](docs/static/Screenshot.png)
![Screenshot of homepage](docs/static/screenshot.jpg)

## Quickstart

Expand Down Expand Up @@ -114,7 +114,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t

Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain.

### Start development database

Expand Down Expand Up @@ -542,7 +542,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t

Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and sender email address into the .env file.

If using the dev container configuration, you will need to set the `DB_HOST` environment variable to "host.docker.internal" in the .env file. Otherwise, set `DB_HOST` to "localhost" for local development. (In production, `DB_HOST` will be set to the hostname of the database server.)

Expand Down Expand Up @@ -988,7 +988,193 @@ Server-side validation remains essential as a security measure against malicious

# Deployment

## Under construction
This application requires two services to be deployed and connected to each other:

1. A PostgreSQL database (the storage layer)
2. A FastAPI app (the application layer)

There are *many* hosting options available for each of these services; this guide will cover only a few of them.

## Deploying and Configuring the PostgreSQL Database

### On Digital Ocean

#### Getting Started

- Create a [DigitalOcean](mdc:https:/www.digitalocean.com) account
- Install the [`doctl` CLI tool](mdc:https:/docs.digitalocean.com/reference/doctl) and authenticate with `doctl auth init`
- Install the [`psql` client](mdc:https:/www.postgresql.org/download)

#### Create a Project

Create a new project to organize your resources:

```bash
# List existing projects
doctl projects list

# Create a new project
doctl projects create --name "YOUR-PROJECT-NAME" --purpose "YOUR-PROJECT-PURPOSE" --environment "Production"
```

#### Set Up a Managed PostgreSQL Database

Create a managed, serverless PostgreSQL database instance:

```bash
doctl databases create your-db-name --engine pg --version 17 --size db-s-1vcpu-1gb --num-nodes 1 --wait
```

Get the database ID from the output of the create command and use it to retrieve the database connection details:

```bash
# Get the database connection details
doctl databases connection "your-database-id" --format Host,Port,User,Password,Database
```

Store these details securely in a `.env.production` file (you will need to set them later in application deployment as production secrets):

```bash
# Database connection parameters
DB_HOST=your-host
DB_PORT=your-port
DB_USER=your-user
DB_PASS=your-password
DB_NAME=your-database
```

You may also want to save your database id, although you can always find it again later by listing your databases with `doctl databases list`.

#### Setting Up a Firewall Rule (after Deploying Your Application Layer)

Note that by default your database is publicly accessible from the Internet, so you should create a firewall rule to restrict access to only your application's IP address once you have deployed the application. The command to do this is:

```bash
doctl databases firewalls append <database-cluster-id> --rule <type>:<value>
```

where `<type>` is `ip_addr` and `<value>` is the IP address of the application server. See the [DigitalOcean documentation](https://docs.digitalocean.com/reference/doctl/reference/databases/firewalls/append/) for more details.

**Note:** You can only complete this step after you have deployed your application layer and obtained a static IP address for the application server.

## Deploying and Configuring the FastAPI App

### On Modal.com

The big advantages of deploying on Modal.com are:
1. that they offer $30/month of free credits for each user, plus generous additional free credit allotments for startups and researchers, and
2. that it's a very user-friendly platform.

The disadvantages are:
1. that Modal is a Python-only platform and cannot run the database layer, so you'll have to deploy that somewhere else,
2. that you'll need to make some modest changes to the codebase to get it to work on Modal, and
3. that Modal offers a [static IP address for the application server](https://modal.com/docs/guide/proxy-ips) only if you pay for a higher-tier plan starting at $250/year, which makes securing the database layer with a firewall rule cost prohibitive.

#### Getting Started

- [Sign up for a Modal.com account](https://modal.com/signup)
- Install modal in the project directory with `uv add modal`
- Run `uv run modal setup` to authenticate with Modal

#### Defining the Modal Image and App

Create a new Python file in the root of your project, for example, `deploy.py`. This file will define the Modal Image and the ASGI app deployment.

1. **Define the Modal Image in `deploy.py`:**
- Use `modal.Image` to define the container environment. Chain methods to install dependencies and add code/files.
- Start with a Debian base image matching your Python version (e.g., 3.13).
- Install necessary system packages (`libpq-dev` for `psycopg2`, `libwebp-dev` for Pillow WebP support).
- Install Python dependencies using `run_commands` with `uv`.
- Add your local Python modules (`routers`, `utils`, `exceptions`) using `add_local_python_source`.
- Add the `static` and `templates` directories using `add_local_dir`. The default behaviour (copying on container startup) is usually fine for development, but consider `copy=True` for production stability if these files are large or rarely change.

```python
# deploy.py
import modal
import os

# Define the base image
image = (
modal.Image.debian_slim(python_version="3.13")
.apt_install("libpq-dev", "libwebp-dev")
.pip_install_from_pyproject("pyproject.toml")
.add_local_python_source("main")
.add_local_python_source("routers")
.add_local_python_source("utils")
.add_local_python_source("exceptions")
.add_local_dir("static", remote_path="/root/static")
.add_local_dir("templates", remote_path="/root/templates")
)

# Define the Modal App
app = modal.App(
name="your-app-name",
image=image,
secrets=[modal.Secret.from_name("your-app-name-secret")]
)
```

2. **Define the ASGI App Function in `deploy.py`:**
- Create a function decorated with `@app.function()` and `@modal.asgi_app()`.
- Inside this function, import your FastAPI application instance from `main.py`.
- Return the FastAPI app instance.
- Use `@modal.concurrent()` to allow the container to handle multiple requests concurrently.

```python
# deploy.py (continued)

# Define the ASGI app function
@app.function(
allow_concurrent_inputs=100 # Adjust concurrency as needed
)
@modal.asgi_app()
def fastapi_app():
# Important: Import the app *inside* the function
# This ensures it runs within the Modal container environment
# and has access to the installed packages and secrets.
# It also ensures the lifespan function (db setup) runs correctly
# with the environment variables provided by the Modal Secret.
from main import app as web_app

return web_app
```

For more information on Modal FastAPI images and applications, see [this guide](https://modal.com/docs/guide/webhooks#how-do-web-endpoints-run-in-the-cloud).

#### Deploying the App

From your terminal, in the root directory of your project, run:

```bash
modal deploy deploy.py
```

Modal will build the image (if it hasn't been built before or if dependencies changed) and deploy the ASGI app. It will output a public URL (e.g., `https://your-username--your-app-name.modal.run`).

#### Setting Up Modal Secrets

The application relies on environment variables stored in `.env` (like `SECRET_KEY`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`, `DB_NAME`, `RESEND_API_KEY`, `BASE_URL`). These sensitive values should be stored securely using Modal Secrets.

Create a Modal Secret either through the Modal UI or CLI. Note that the name of the secret has to match the secret name you used in the `deploy.py` file, above (e.g., `your-app-name-secret`).

```bash
# Example using CLI
modal secret create your-app-name-secret \
SECRET_KEY='your_actual_secret_key' \
DB_USER='your_db_user' \
DB_PASSWORD='your_db_password' \
DB_HOST='your_external_db_host' \
DB_PORT='your_db_port' \
DB_NAME='your_db_name' \
RESEND_API_KEY='your_resend_api_key' \
BASE_URL='https://your-username--your-app-name-serve.modal.run'
```

**Important:** Ensure `DB_HOST` points to your *cloud* database host address, not `localhost` or `host.docker.internal`.

#### Testing the Deployment

Access the provided Modal URL in your browser. Browse the site and test the registration and password reset features to ensure database and Resend connections work.

# Contributing

Expand Down
Binary file modified docs/static/schema.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions exceptions/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,9 @@ def __init__(self, user: User, access_token: str, refresh_token: str):
self.user = user
self.access_token = access_token
self.refresh_token = refresh_token


# Define custom exception for email sending failure
class EmailSendFailedError(Exception):
"""Custom exception for email sending failures."""
pass
69 changes: 68 additions & 1 deletion exceptions/http_exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,4 +144,71 @@ class InvalidImageError(HTTPException):
"""Raised when an invalid image is uploaded"""

def __init__(self, message: str = "Invalid image file"):
super().__init__(status_code=400, detail=message)
super().__init__(status_code=400, detail=message)


# --- Invitation-specific Errors ---

class UserIsAlreadyMemberError(HTTPException):
"""Raised when trying to invite a user who is already a member of the organization."""
def __init__(self):
super().__init__(
status_code=409,
detail="This user is already a member of the organization."
)


class ActiveInvitationExistsError(HTTPException):
"""Raised when trying to invite a user for whom an active invitation already exists."""
def __init__(self):
super().__init__(
status_code=409,
detail="An active invitation already exists for this email address in this organization."
)


class InvalidRoleForOrganizationError(HTTPException):
"""Raised when a role provided does not belong to the target organization.
Note: If the role ID simply doesn't exist, a standard 404 RoleNotFoundError should be raised.
"""
def __init__(self):
super().__init__(
status_code=400,
detail="The selected role does not belong to this organization."
)


class InvitationEmailSendError(HTTPException):
"""Raised when the invitation email fails to send."""
def __init__(self):
super().__init__(
status_code=500, # Internal Server Error seems appropriate
detail="Failed to send invitation email. Please try again later or contact support."
)


class InvalidInvitationTokenError(HTTPException):
"""Raised when an invitation token is invalid, expired, or not found."""
def __init__(self):
super().__init__(
status_code=404,
detail="Invitation not found or expired"
)


class InvitationEmailMismatchError(HTTPException):
"""Raised when a user attempts to accept an invitation sent to a different email address."""
def __init__(self):
super().__init__(
status_code=403,
detail="This invitation was sent to a different email address"
)


class InvitationProcessingError(HTTPException):
"""Raised when an error occurs during the processing of a valid invitation."""
def __init__(self, detail: str = "Failed to process invitation. Please try again later."):
super().__init__(
status_code=500, # Internal Server Error
detail=detail
)
2 changes: 1 addition & 1 deletion index.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Generate a 256 bit secret key with `openssl rand -base64 32` and paste it into t

Set your desired database name, username, and password in the .env file.

To use password recovery, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key into the .env file.
To use password recovery and other email features, register a [Resend](https://resend.com/) account, verify a domain, get an API key, and paste the API key and the email address you want to send emails from into the .env file. Note that you will need to [verify a domain through the Resend dashboard](https://resend.com/docs/dashboard/domains/introduction) to send emails from that domain.

### Start development database

Expand Down
3 changes: 2 additions & 1 deletion main.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from fastapi.templating import Jinja2Templates
from fastapi.exceptions import RequestValidationError
from starlette.exceptions import HTTPException as StarletteHTTPException
from routers import account, dashboard, organization, role, user, static_pages
from routers import account, dashboard, organization, role, user, static_pages, invitation
from utils.dependencies import (
get_optional_user
)
Expand Down Expand Up @@ -46,6 +46,7 @@ async def lifespan(app: FastAPI):

app.include_router(account.router)
app.include_router(dashboard.router)
app.include_router(invitation.router)
app.include_router(organization.router)
app.include_router(role.router)
app.include_router(static_pages.router)
Expand Down
Loading