Skip to content

Add SMTP Send Email function example #132

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

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
# Contributing

## Development
This is the place holder pending further discussion on a formal contributing process/guidelines.

## Pull Request Process

This is the place holder pending further discussion on a formal contributing process/guidelines.

### Prerequisites

Expand Down
4 changes: 3 additions & 1 deletion readme.md → README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ This repository contains various examples demonstrating how to use the Restack A

## Getting Started

To run the examples, in general the process looks like the below, but reference the example README.md for example specific instructions.

1. Clone this repository:

```bash
Expand All @@ -19,7 +21,7 @@ This repository contains various examples demonstrating how to use the Restack A
2. Navigate to the example you want to explore:

```bash
cd examples-python/examples/<example-name>
cd examples-python/<example-name>
```

3. Install dependencies using Poetry:
Expand Down
14 changes: 14 additions & 0 deletions email_smtp_sender/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# SMTP Environment Variables
SMTP_SERVER = "smtp.mailgun.org"
SMTP_PORT = 587
SMTP_USERNAME = "[email protected]" # Usually starts with 'postmaster@'
SMTP_PASSWORD = "PASSWD"
SENDER_EMAIL = "[email protected]"

# Restack Cloud (Optional)

# RESTACK_ENGINE_ID=<your-engine-id>
# RESTACK_ENGINE_API_KEY=<your-engine-api-key>
# RESTACK_ENGINE_API_ADDRESS=<your-engine-api-address>
# RESTACK_ENGINE_ADDRESS=<your-engine-address>
# RESTACK_CLOUD_TOKEN=<your-cloud-token>
3 changes: 3 additions & 0 deletions email_smtp_sender/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.DS_Store
.env
poetry.lock
91 changes: 91 additions & 0 deletions email_smtp_sender/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# Restack AI - SMTP Send Email Example


## Why SMTP in 2025?



### The SMTP Advantage

*"But why not use [insert latest buzzword solution here]?"*

Listen, I get it. You're probably thinking "SMTP? In 2025? What is this, a museum?" But hear me out:

Want to send emails from `[email protected]`... `[email protected]`? All you need is:
1. A domain (your digital real estate)
2. Basic DNS setup
3. A working SMTP server

## Prerequisites

- Python 3.10 or higher
- Poetry (for dependency management)
- Docker (for running the Restack services)
- SMTP Credentials

## Usage

Run Restack local engine with Docker:

```bash
docker run -d --pull always --name restack -p 5233:5233 -p 6233:6233 -p 7233:7233 ghcr.io/restackio/restack:main
```

Open the web UI to see the workflows: http://localhost:5233

---

Clone this repository:

```bash
git clone https://github.com/restackio/examples-python
cd examples-python/smtp_send_email/
```

---

Reference `.env.example` to create a `.env` file with your SMTP credentials:

```bash
cp .env.example .env
```

```
SMTP_SERVER = "smtp.mailgun.org"
SMTP_PORT = 587
SMTP_USERNAME = "[email protected]" # Usually starts with 'postmaster@'
SMTP_PASSWORD = "PASSWD"
SENDER_EMAIL = "[email protected]"
```

Update the `.env` file with the required ENVVARs

---

Install dependencies using Poetry:

```bash
poetry env use 3.12
poetry shell
poetry install
poetry env info # Optional: copy the interpreter path to use in your IDE (e.g. Cursor, VSCode, etc.)
```


Run the [services](https://docs.restack.io/libraries/python/services):

```bash
poetry run services
```

This will start the Restack service with the defined [workflows](https://docs.restack.io/libraries/python/workflows) and [functions](https://docs.restack.io/libraries/python/functions).

In the Dev UI, you can use the workflow to manually kick off a test with an example JSON post, and then start inegrating more steps into a workflow that requires sending a SMTP email.

## Development mode

If you want to run the services in development mode, you can use the following command to watch for file changes, if you choose to copy this to build your workflow off of:

```bash
poetry run dev
```
24 changes: 24 additions & 0 deletions email_smtp_sender/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
[tool.poetry]
name = "smtp_send_email"
version = "0.0.1"
description = "Example workflow and function for sending an email using SMTP"
authors = [
"CyberAstronaut <[email protected]>",
]
readme = "README.md"
packages = [{include = "src", format = ["sdist"]}]

[tool.poetry.dependencies]
python = ">=3.10,<4.0"
watchfiles = "^1.0.0"
pydantic = "^2.10.5"
python-dotenv = "1.0.1"
restack-ai = "^0.0.52"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.poetry.scripts]
dev = "src.services:watch_services"
services = "src.services:run_services"
Empty file.
30 changes: 30 additions & 0 deletions email_smtp_sender/src/client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import os
from restack_ai import Restack
from restack_ai.restack import CloudConnectionOptions
from dotenv import load_dotenv

from src.functions.smtp_send_email import load_smtp_config
# Load environment variables from a .env file
load_dotenv()

# Call and validate environment variables for critical functions - prevents app from running if we don't have the necessary environment variables
# Possible standard practice for all functions that require environment variables?
# Most examples have long blocks of checking for environment variables, so this could be a good way to consolidate that to a standard function we
# can short circuit and kill the app if we know we will have a failure state.

## Verify ENV VARS present for SMTP Send Email function
load_smtp_config()

engine_id = os.getenv("RESTACK_ENGINE_ID")
address = os.getenv("RESTACK_ENGINE_ADDRESS")
api_key = os.getenv("RESTACK_ENGINE_API_KEY")

connection_options = CloudConnectionOptions(
engine_id=engine_id,
address=address,
api_key=api_key
)
client = Restack(connection_options)


#
Empty file.
74 changes: 74 additions & 0 deletions email_smtp_sender/src/functions/smtp_send_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import os
from restack_ai.function import function, FunctionFailure, log
from dataclasses import dataclass
from dotenv import load_dotenv

import smtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart

import json

load_dotenv()

@dataclass
class SendEmailInput:
to_email: str
subject: str
body: str

@function.defn()
async def smtp_send_email(input: SendEmailInput):

config = load_smtp_config()

# Verify input.to_email is a valid email address - quick n dirty
if not "@" in input.to_email:
raise FunctionFailure("SMTPSendEmail: input.to_email not valid email", non_retryable=True)

# Create message
message = MIMEMultipart()
message["From"] = config.get("SMTP_FROM_EMAIL")
message["To"] = input.to_email
message["Subject"] = input.subject

# Add body
message.attach(MIMEText(input.body, "plain"))

try:
# Create SMTP session
with smtplib.SMTP(config.get("SMTP_SERVER"), config.get("SMTP_PORT")) as server:
server.starttls()
server.login(config.get("SMTP_USERNAME"), config.get("SMTP_PASSWORD"))

# Send email
print(f"Sending email to {input.to_email}")
server.send_message(message)
print("Email sent successfully")

return f"Email sent successfully to {input.to_email}"

except Exception as e:
log.error("Failed to send email", error=e)

errorMessage = json.dumps({"error": f"Failed to send email {e}"})
raise FunctionFailure(errorMessage, non_retryable=False)


def load_smtp_config():
"""Validates that we have all essential environment variables set; raises an exception if not."""

required_vars = {
"SMTP_SERVER": os.getenv("SMTP_SERVER"),
"SMTP_PORT": os.getenv("SMTP_PORT"),
"SMTP_USERNAME": os.getenv("SMTP_USERNAME"),
"SMTP_PASSWORD": os.getenv("SMTP_PASSWORD"),
"SMTP_FROM_EMAIL": os.getenv("SMTP_FROM_EMAIL"),
}

missing = [var for var, value in required_vars.items() if not value]

if missing:
raise FunctionFailure(f"Missing required environment variables: {missing}", non_retryable=True)

return required_vars
33 changes: 33 additions & 0 deletions email_smtp_sender/src/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import os
import asyncio
from src.client import client
from watchfiles import run_process
# import webbrowser

## Workflow and function imports
from src.workflows.send_email import SendEmailWorkflow
from src.functions.smtp_send_email import smtp_send_email

async def main():
await asyncio.gather(
client.start_service(
workflows=[SendEmailWorkflow],
functions=[smtp_send_email]
)
)

def run_services():
try:
asyncio.run(main())
except KeyboardInterrupt:
print("Service interrupted by user. Exiting gracefully.")

def watch_services():
watch_path = os.getcwd()
print(f"Watching {watch_path} and its subdirectories for changes...")
# Opens default browser to Dev UI
# webbrowser.open("http://localhost:5233")
run_process(watch_path, recursive=True, target=run_services)

if __name__ == "__main__":
run_services()
Empty file.
29 changes: 29 additions & 0 deletions email_smtp_sender/src/workflows/send_email.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
from restack_ai.workflow import workflow, import_functions, log, RetryPolicy
from datetime import timedelta
from pydantic import BaseModel, Field

with import_functions():
from src.functions.smtp_send_email import smtp_send_email, SendEmailInput

class WorkflowInputParams(BaseModel):
body: str = Field(default="SMTP Email Body Content")
subject: str = Field(default="SMTP Email Subject")
to_email: str = Field(default="SMTP Email Recipient Address")

@workflow.defn()
class SendEmailWorkflow:
@workflow.run
async def run(self, input: WorkflowInputParams):

emailState = await workflow.step(
smtp_send_email,
SendEmailInput(
body=input.body,
subject=input.subject,
to_email=input.to_email,
),
start_to_close_timeout=timedelta(seconds=15),
retry_policy=RetryPolicy(maximum_attempts=1)
)

return emailState