Skip to content

Commit

Permalink
add email
Browse files Browse the repository at this point in the history
  • Loading branch information
eelcovdw committed Oct 9, 2024
1 parent 6a9addd commit 1756718
Show file tree
Hide file tree
Showing 8 changed files with 554 additions and 0 deletions.
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ version = "0.1.3"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.9"

# add using uv add <pip package>
dependencies = [
"cryptography>=43.0.1",
"requests>=2.32.3",
Expand All @@ -18,6 +20,7 @@ dependencies = [
"setuptools>=75.1.0",
"postmarker>=1.0",
"watchdog>=5.0.2",
"pydantic-settings>=2.5.2",
]

[project.optional-dependencies]
Expand All @@ -41,6 +44,7 @@ dev-dependencies = [
"pytest-xdist[psutil]>=3.6.1",
"pytest>=8.3.3",
"httpx>=0.27.2",
"pre-commit>=4.0.0",
]

[tool.setuptools]
Expand Down
Empty file.
17 changes: 17 additions & 0 deletions syftbox/server/notifications/notifications.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import jinja2

from syftbox.server.users.user import User

from ..users.user import User
from .email_templates import create_token_email

jinja_env = jinja2.Environment(loader=jinja2.PackageLoader("syftbox", "server/templates/email"))


def create_token_email(user: User) -> str:
template = jinja_env.get_template("token_email.html")
return template.render(email=user.email, token=user.token)


def send_token_email(user: User) -> None:
email_body = create_token_email(user)
180 changes: 180 additions & 0 deletions syftbox/server/notifications/server_url.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
from __future__ import annotations

import copy
import logging
import os
import re
from urllib.parse import urlparse

import requests
from typing_extensions import Self

logger = logging.getLogger(__name__)


def str_to_bool(bool_str: str | None) -> bool:
result = False
bool_str = str(bool_str).lower()
if bool_str == "true" or bool_str == "1":
result = True
return result


def verify_tls() -> bool:
return not str_to_bool(str(os.environ.get("IGNORE_TLS_ERRORS", "0")))


class ServerURL:
@classmethod
def from_url(cls, url: str | ServerURL) -> ServerURL:
if isinstance(url, ServerURL):
return url
try:
# urlparse doesnt handle no protocol properly
if "://" not in url:
url = "http://" + url
parts = urlparse(url)
host_or_ip_parts = parts.netloc.split(":")
# netloc is host:port
port = 80
if len(host_or_ip_parts) > 1:
port = int(host_or_ip_parts[1])
host_or_ip = host_or_ip_parts[0]
if parts.scheme == "https":
port = 443
return ServerURL(
host_or_ip=host_or_ip,
path=parts.path,
port=port,
protocol=parts.scheme,
query=getattr(parts, "query", ""),
)
except Exception as e:
logger.error(f"Failed to convert url: {url} to ServerURL. {e}")
raise e

def __init__(
self,
protocol: str = "http",
host_or_ip: str = "localhost",
port: int | None = 80,
path: str = "",
query: str = "",
) -> None:
# in case a preferred port is listed but its not clear if an alternative
# port was included in the supplied host_or_ip:port combo passed in earlier
match_port = re.search(":[0-9]{1,5}", host_or_ip)
if match_port:
sub_server_url: ServerURL = ServerURL.from_url(host_or_ip)
host_or_ip = str(sub_server_url.host_or_ip) # type: ignore
port = int(sub_server_url.port) # type: ignore
protocol = str(sub_server_url.protocol) # type: ignore
path = str(sub_server_url.path) # type: ignore

prtcl_pattrn = "://"
if prtcl_pattrn in host_or_ip:
protocol = host_or_ip[: host_or_ip.find(prtcl_pattrn)]
start_index = host_or_ip.find(prtcl_pattrn) + len(prtcl_pattrn)
host_or_ip = host_or_ip[start_index:]

self.host_or_ip = host_or_ip
self.path: str = path
self.port = port
self.protocol = protocol
self.query = query

def with_path(self, path: str) -> Self:
dupe = copy.copy(self)
dupe.path = path
return dupe

def as_container_host(self, container_host: str | None = None) -> Self:
if self.host_or_ip not in [
"localhost",
"host.docker.internal",
"host.k3d.internal",
]:
return self

if container_host is None:
# TODO: we could move config.py to syft and then the Settings singleton
# could be importable in all parts of the code
container_host = os.getenv("CONTAINER_HOST", None)

if container_host:
if container_host == "docker":
hostname = "host.docker.internal"
elif container_host == "podman":
hostname = "host.containers.internal"
else:
hostname = "host.k3d.internal"
else:
# convert it back for non container clients
hostname = "localhost"

return self.__class__(
protocol=self.protocol,
host_or_ip=hostname,
port=self.port,
path=self.path,
)

@property
def query_string(self) -> str:
query_string = ""
if len(self.query) > 0:
query_string = f"?{self.query}"
return query_string

@property
def url(self) -> str:
return f"{self.base_url}{self.path}{self.query_string}"

@property
def url_no_port(self) -> str:
return f"{self.base_url_no_port}{self.path}{self.query_string}"

@property
def base_url(self) -> str:
return f"{self.protocol}://{self.host_or_ip}:{self.port}"

@property
def base_url_no_port(self) -> str:
return f"{self.protocol}://{self.host_or_ip}"

@property
def url_no_protocol(self) -> str:
return f"{self.host_or_ip}:{self.port}{self.path}"

@property
def url_path(self) -> str:
return f"{self.path}{self.query_string}"

def to_tls(self) -> ServerURL:
if self.protocol == "https":
return self

# TODO: only ignore ssl in dev mode
r = requests.get( # nosec
self.base_url, verify=verify_tls()
) # ignore ssl cert if its fake
new_base_url = r.url
if new_base_url.endswith("/"):
new_base_url = new_base_url[0:-1]
return self.__class__.from_url(url=f"{new_base_url}{self.path}{self.query_string}")

def __repr__(self) -> str:
return f"<{type(self).__name__} {self.url}>"

def __str__(self) -> str:
return self.url

def __hash__(self) -> int:
return hash(self.__str__())

def __copy__(self) -> ServerURL:
return self.__class__.from_url(self.url)

def set_port(self, port: int) -> Self:
self.port = port
return self
75 changes: 75 additions & 0 deletions syftbox/server/notifications/smpt_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# stdlib
import logging
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText

from pydantic import BaseModel

from .server_url import ServerURL

SOCKET_TIMEOUT = 5 # seconds

logger = logging.getLogger(__name__)


class SMTPClient(BaseModel):
server: str
port: int
password: str | None = None
username: str | None = None

def create_email(self, sender: str, receiver: list[str], subject: str, body: str) -> MIMEMultipart:
msg = MIMEMultipart("alternative")
msg["From"] = sender
msg["To"] = ", ".join(receiver)
msg["Subject"] = subject
msg.attach(MIMEText(body, "html"))
return msg

def send(self, sender: str, receiver: list[str], subject: str, body: str) -> None:
if not (subject and body and receiver):
raise ValueError("Subject, body, and recipient email(s) are required")

msg = self.create_email(sender, receiver, subject, body)

mail_url = ServerURL.from_url(f"smtp://{self.server}:{self.port}")
mail_url = mail_url.as_container_host()
try:
with smtplib.SMTP(mail_url.host_or_ip, mail_url.port, timeout=SOCKET_TIMEOUT) as server:
server.ehlo()
if server.has_extn("STARTTLS"):
server.starttls()
server.ehlo()
if self.username and self.password:
server.login(self.username, self.password)
text = msg.as_string()
server.sendmail(sender, ", ".join(receiver), text)
return None
except Exception as e:
logger.error(f"Unable to send email. {e}")
raise e

@classmethod
def check_credentials(cls, server: str, port: int, username: str, password: str) -> bool:
"""Check if the credentials are valid.
Returns:
bool: True if the credentials are valid, False otherwise.
"""
try:
mail_url = ServerURL.from_url(f"smtp://{server}:{port}")
mail_url = mail_url.as_container_host()

print(f"> Validating SMTP settings: {mail_url}")
with smtplib.SMTP(mail_url.host_or_ip, mail_url.port, timeout=SOCKET_TIMEOUT) as smtp_server:
smtp_server.ehlo()
if smtp_server.has_extn("STARTTLS"):
smtp_server.starttls()
smtp_server.ehlo()
smtp_server.login(username, password)
return True
except Exception as e:
message = f"SMTP check_credentials failed. {e}"
logger.error(message)
raise e
25 changes: 25 additions & 0 deletions syftbox/server/settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from pathlib import Path

from pydantic_settings import BaseSettings, SettingsConfigDict


class SMTPSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SMTP_")
tls: bool = True
port: int = 587
host: str
username: str
password: str
sender: str


class ServerSettings(BaseSettings):
model_config = SettingsConfigDict(env_prefix="SYFTBOX_")
data_folder: Path = Path("data")
snapshot_folder: Path = Path("data/snapshot")
user_file_path: Path = Path("data/users.json")
smtp: "SMTPSettings" = SMTPSettings()

@property
def folders(self):
return [self.data_folder, self.snapshot_folder]
57 changes: 57 additions & 0 deletions syftbox/server/templates/email/token_email.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<!doctype html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
color: #333;
margin: 0;
padding: 0;
}
.container {
max-width: 600px;
margin: 50px auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
h1 {
color: #333;
text-align: center;
}
p {
font-size: 16px;
line-height: 1.5;
}
code {
display: block;
background-color: #f0f0f0;
color: #ff8c00;
padding: 10px;
font-size: 14px;
margin: 20px auto;
border-radius: 4px;
width: 90%;
word-wrap: break-word;
}
.footer {
text-align: center;
font-size: 12px;
color: #aaa;
margin-top: 20px;
}
</style>
</head>
<body>
<div class="container">
<h1>Welcome!</h1>
<p>
Use the following command in your CLI to complete your registration:
</p>
<code> syftbox register --email {{ email }} --token {{ token }} </code>
<p>If you did not request this, please ignore this email.</p>
</div>
</body>
</html>
Loading

0 comments on commit 1756718

Please sign in to comment.