diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 28bad810..69fc7279 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/readme.md b/README.md similarity index 87% rename from readme.md rename to README.md index d14dcb62..4a96f23e 100644 --- a/readme.md +++ b/README.md @@ -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 @@ -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/ + cd examples-python/ ``` 3. Install dependencies using Poetry: diff --git a/email_smtp_sender/.env.example b/email_smtp_sender/.env.example new file mode 100644 index 00000000..b5aff5a0 --- /dev/null +++ b/email_smtp_sender/.env.example @@ -0,0 +1,14 @@ +# SMTP Environment Variables +SMTP_SERVER = "smtp.mailgun.org" +SMTP_PORT = 587 +SMTP_USERNAME = "postmaster@domain.xyz" # Usually starts with 'postmaster@' +SMTP_PASSWORD = "PASSWD" +SENDER_EMAIL = "restack@mg.domain.xyz" + +# Restack Cloud (Optional) + +# RESTACK_ENGINE_ID= +# RESTACK_ENGINE_API_KEY= +# RESTACK_ENGINE_API_ADDRESS= +# RESTACK_ENGINE_ADDRESS= +# RESTACK_CLOUD_TOKEN= diff --git a/email_smtp_sender/.gitignore b/email_smtp_sender/.gitignore new file mode 100644 index 00000000..78f7ac68 --- /dev/null +++ b/email_smtp_sender/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.env +poetry.lock diff --git a/email_smtp_sender/README.md b/email_smtp_sender/README.md new file mode 100644 index 00000000..3fc90b8a --- /dev/null +++ b/email_smtp_sender/README.md @@ -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 `workflow1@yourdomain.com`... `workflow100@yourdomain.com`? 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 = "postmaster@domain.xyz" # Usually starts with 'postmaster@' +SMTP_PASSWORD = "PASSWD" +SENDER_EMAIL = "restack@mg.domain.xyz" +``` + +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 +``` diff --git a/email_smtp_sender/pyproject.toml b/email_smtp_sender/pyproject.toml new file mode 100644 index 00000000..2c058514 --- /dev/null +++ b/email_smtp_sender/pyproject.toml @@ -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 <42648291+CyberAstronaut101@users.noreply.github.com>", +] +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" diff --git a/email_smtp_sender/src/__init__.py b/email_smtp_sender/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/email_smtp_sender/src/client.py b/email_smtp_sender/src/client.py new file mode 100644 index 00000000..b6db7391 --- /dev/null +++ b/email_smtp_sender/src/client.py @@ -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) + + +# \ No newline at end of file diff --git a/email_smtp_sender/src/functions/__init__.py b/email_smtp_sender/src/functions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/email_smtp_sender/src/functions/smtp_send_email.py b/email_smtp_sender/src/functions/smtp_send_email.py new file mode 100644 index 00000000..05fee4e2 --- /dev/null +++ b/email_smtp_sender/src/functions/smtp_send_email.py @@ -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 \ No newline at end of file diff --git a/email_smtp_sender/src/services.py b/email_smtp_sender/src/services.py new file mode 100644 index 00000000..809b1102 --- /dev/null +++ b/email_smtp_sender/src/services.py @@ -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() diff --git a/email_smtp_sender/src/workflows/__init__.py b/email_smtp_sender/src/workflows/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/email_smtp_sender/src/workflows/send_email.py b/email_smtp_sender/src/workflows/send_email.py new file mode 100644 index 00000000..94c5679f --- /dev/null +++ b/email_smtp_sender/src/workflows/send_email.py @@ -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