diff --git a/docker-compose.yml b/docker-compose.yml
index 7674ec2937d8..1fede36bb7c2 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,16 @@ services:
- ALLOW_SIGNUP=true
- API_URL=http://mealie-api:80
+ # =====================================
+ # Email Configuration
+ # - SMTP_HOST=
+ # - SMTP_PORT=587
+ # - SMTP_FROM_NAME=Mealie
+ # - SMTP_TLS=true
+ # - SMTP_FROM_EMAIL=
+ # - SMTP_USER=
+ # - SMTP_PASSWORD=
+
# =====================================
# Light Mode Config
- THEME_LIGHT_PRIMARY=#E58325
diff --git a/docs/docs/documentation/getting-started/install.md b/docs/docs/documentation/getting-started/install.md
index 83e69a2a26af..df533b947170 100644
--- a/docs/docs/documentation/getting-started/install.md
+++ b/docs/docs/documentation/getting-started/install.md
@@ -112,48 +112,79 @@ services:
POSTGRES_USER: mealie
```
-## mealie-api Env Variables
+## API Environment Variables
-| Variables | Default | Description |
-| ----------------- | :-------------------: | --------------------------------------------------------------------------------------------------------------------------------- |
-| PUID | 911 | UserID permissions between host OS and container |
-| PGID | 911 | GroupID permissions between host OS and container |
-| DEFAULT_GROUP | Home | The default group for users |
-| DEFAULT_EMAIL | changeme@email.com | The default username for the superuser |
-| BASE_URL | http://localhost:8080 | Used for Notifications |
-| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
-| POSTGRES_USER | mealie | Postgres database user |
-| POSTGRES_PASSWORD | mealie | Postgres database password |
-| POSTGRES_SERVER | postgres | Postgres database server address |
-| POSTGRES_PORT | 5432 | Postgres database port |
-| POSTGRES_DB | mealie | Postgres database name |
-| TOKEN_TIME | 2 | The time in hours that a login/auth token is valid |
-| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
-| API_DOCS | True | Turns on/off access to the API documentation locally. |
-| TZ | UTC | Must be set to get correct date/time on the server |
-| WORKERS_PER_CORE | 1 | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] |
-| MAX_WORKERS | | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] |
-| WEB_CONCURRENCY | 2 | Override the automatic definition of number of workers. More info [here][web_concurrency] |
+### General
-## mealie-frontend Env Variables
+| Variables | Default | Description |
+| ------------- | :-------------------: | ----------------------------------------------------------------------------------- |
+| PUID | 911 | UserID permissions between host OS and container |
+| PGID | 911 | GroupID permissions between host OS and container |
+| DEFAULT_GROUP | Home | The default group for users |
+| DEFAULT_EMAIL | changeme@email.com | The default username for the superuser |
+| BASE_URL | http://localhost:8080 | Used for Notifications |
+| TOKEN_TIME | 2 | The time in hours that a login/auth token is valid |
+| API_PORT | 9000 | The port exposed by backend API. **Do not change this if you're running in Docker** |
+| API_DOCS | True | Turns on/off access to the API documentation locally. |
+| TZ | UTC | Must be set to get correct date/time on the server |
-| Variables | Default | Description |
-| --------------------- | :-----: | ---------------------------------- |
-| ALLOW_SIGNUP | true | Allows anyone to signup for Mealie |
-| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
-| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
-| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
-| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
-| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
-| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
-| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
-| DARK_LIGHT_PRIMARY | #E58325 | Dark Theme Config Variable |
-| DARK_LIGHT_ACCENT | #007A99 | Dark Theme Config Variable |
-| DARK_LIGHT_SECONDARY | #973542 | Dark Theme Config Variable |
-| DARK_LIGHT_SUCCESS | #43A047 | Dark Theme Config Variable |
-| DARK_LIGHT_INFO | #1976D2 | Dark Theme Config Variable |
-| DARK_LIGHT_WARNING | #FF6D00 | Dark Theme Config Variable |
-| DARK_LIGHT_ERROR | #EF5350 | Dark Theme Config Variable |
+
+### Database
+
+| Variables | Default | Description |
+| ----------------- | :------: | -------------------------------- |
+| DB_ENGINE | sqlite | Optional: 'sqlite', 'postgres' |
+| POSTGRES_USER | mealie | Postgres database user |
+| POSTGRES_PASSWORD | mealie | Postgres database password |
+| POSTGRES_SERVER | postgres | Postgres database server address |
+| POSTGRES_PORT | 5432 | Postgres database port |
+| POSTGRES_DB | mealie | Postgres database name |
+
+### Email
+
+| Variables | Default | Description |
+| --------------- | :-----: | ------------------ |
+| SMTP_HOST | None | Required For email |
+| SMTP_PORT | 587 | Required For email |
+| SMTP_FROM_NAME | Mealie | Required For email |
+| SMTP_TLS | true | Required For email |
+| SMTP_FROM_EMAIL | None | Required For email |
+| SMTP_USER | None | Required For email |
+| SMTP_PASSWORD | None | Required For email |
+
+### Webworkers
+| Variables | Default | Description |
+| ---------------- | :-----: | --------------------------------------------------------------------------------------------------------------------------------- |
+| WORKERS_PER_CORE | 1 | Set the number of workers to the number of CPU cores multiplied by this value (Value \* CPUs). More info [here][workers_per_core] |
+| MAX_WORKERS | | Set the maximum number of workers to use. Default is not set meaning unlimited. More info [here][max_workers] |
+| WEB_CONCURRENCY | 2 | Override the automatic definition of number of workers. More info [here][web_concurrency] |
+
+
+## Frontend Environment Variables
+
+### General
+
+| Variables | Default | Description |
+| ------------ | :-----: | ---------------------------------- |
+| ALLOW_SIGNUP | true | Allows anyone to signup for Mealie |
+
+## Themeing
+| Variables | Default | Description |
+| --------------------- | :-----: | --------------------------- |
+| THEME_LIGHT_PRIMARY | #E58325 | Light Theme Config Variable |
+| THEME_LIGHT_ACCENT | #007A99 | Light Theme Config Variable |
+| THEME_LIGHT_SECONDARY | #973542 | Light Theme Config Variable |
+| THEME_LIGHT_SUCCESS | #43A047 | Light Theme Config Variable |
+| THEME_LIGHT_INFO | #1976D2 | Light Theme Config Variable |
+| THEME_LIGHT_WARNING | #FF6D00 | Light Theme Config Variable |
+| THEME_LIGHT_ERROR | #EF5350 | Light Theme Config Variable |
+| DARK_LIGHT_PRIMARY | #E58325 | Dark Theme Config Variable |
+| DARK_LIGHT_ACCENT | #007A99 | Dark Theme Config Variable |
+| DARK_LIGHT_SECONDARY | #973542 | Dark Theme Config Variable |
+| DARK_LIGHT_SUCCESS | #43A047 | Dark Theme Config Variable |
+| DARK_LIGHT_INFO | #1976D2 | Dark Theme Config Variable |
+| DARK_LIGHT_WARNING | #FF6D00 | Dark Theme Config Variable |
+| DARK_LIGHT_ERROR | #EF5350 | Dark Theme Config Variable |
## Raspberry Pi 4
diff --git a/docs/docs/overrides/api.html b/docs/docs/overrides/api.html
index 743c83c72a24..643a32de05f0 100644
--- a/docs/docs/overrides/api.html
+++ b/docs/docs/overrides/api.html
@@ -14,7 +14,7 @@
diff --git a/frontend/api/class-interfaces/email.ts b/frontend/api/class-interfaces/email.ts
new file mode 100644
index 000000000000..e137f0cc6875
--- /dev/null
+++ b/frontend/api/class-interfaces/email.ts
@@ -0,0 +1,28 @@
+import { BaseAPI } from "./_base";
+
+const routes = {
+ base: "/api/admin/email",
+};
+
+export interface CheckEmailResponse {
+ ready: boolean;
+}
+
+export interface TestEmailResponse {
+ success: boolean;
+ error: string;
+}
+
+export interface TestEmailPayload {
+ email: string;
+}
+
+export class EmailAPI extends BaseAPI {
+ check() {
+ return this.requests.get(routes.base);
+ }
+
+ test(payload: TestEmailPayload) {
+ return this.requests.post(routes.base, payload);
+ }
+}
diff --git a/frontend/api/index.ts b/frontend/api/index.ts
index 0671b11d1a67..53e216ddc4b5 100644
--- a/frontend/api/index.ts
+++ b/frontend/api/index.ts
@@ -15,6 +15,7 @@ import { WebhooksAPI } from "./class-interfaces/group-webhooks";
import { AdminAboutAPI } from "./class-interfaces/admin-about";
import { RegisterAPI } from "./class-interfaces/user-registration";
import { MealPlanAPI } from "./class-interfaces/group-mealplan";
+import { EmailAPI } from "./class-interfaces/email";
import { ApiRequestInstance } from "~/types/api";
class AdminAPI {
@@ -50,6 +51,7 @@ class Api {
public groupWebhooks: WebhooksAPI;
public register: RegisterAPI;
public mealplans: MealPlanAPI;
+ public email: EmailAPI;
// Utils
public upload: UploadFile;
@@ -83,6 +85,8 @@ class Api {
this.upload = new UploadFile(requests);
this.utils = new UtilsAPI(requests);
+ this.email = new EmailAPI(requests);
+
Object.freeze(this);
Api.instance = this;
}
diff --git a/frontend/components/global/BaseCardSectionTitle.vue b/frontend/components/global/BaseCardSectionTitle.vue
index 5ea6236f4c0e..80b24782d990 100644
--- a/frontend/components/global/BaseCardSectionTitle.vue
+++ b/frontend/components/global/BaseCardSectionTitle.vue
@@ -1,7 +1,11 @@
- {{ title }}
-
+
+
+ {{ icon }}
+
+ {{ title }}
+
@@ -16,6 +20,10 @@ export default {
type: String,
default: "Place Holder",
},
+ icon: {
+ type: String,
+ default: "",
+ },
},
};
diff --git a/frontend/pages/admin/site-settings.vue b/frontend/pages/admin/site-settings.vue
index d70e5d3edd4c..500f5b2b1b01 100644
--- a/frontend/pages/admin/site-settings.vue
+++ b/frontend/pages/admin/site-settings.vue
@@ -1,21 +1,127 @@
-
-
- Lorem, ipsum dolor sit amet consectetur adipisicing elit. Alias error provident, eveniet, laboriosam assumenda
- earum amet quaerat vel consequatur molestias sed enim. Adipisci a consequuntur dolor culpa expedita voluptatem
- praesentium optio iste atque, ea reiciendis iure non aut suscipit modi ducimus ratione, quam numquam quaerat
- distinctio illum nemo. Dicta, doloremque!
-
+
+
+
+
+
+ {{ $t("settings.site-settings") }}
+
+
+
+
+
+
+
+ {{ ready ? $globals.icons.check : $globals.icons.close }}
+
+
+
+
+ Email Configuration Status
+
+
+ {{ ready ? "Ready" : "Not Ready - Check Env Variables" }}
+
+
+
+
+
+
+
+ {{ $globals.icons.email }}
+ {{ $t("general.test") }}
+
+
+
+
+
+
+ Email Test Result: {{ success ? "Succeeded" : "Failed" }}
+ Errors: {{ error }}
+
+
+
diff --git a/frontend/static/svgs/admin-site-settings.svg b/frontend/static/svgs/admin-site-settings.svg
new file mode 100644
index 000000000000..d804dfc4af4e
--- /dev/null
+++ b/frontend/static/svgs/admin-site-settings.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/mealie/core/config.py b/mealie/core/config.py
index 4bdf221ce7bf..005ad1147b4f 100644
--- a/mealie/core/config.py
+++ b/mealie/core/config.py
@@ -156,10 +156,6 @@ class AppSettings(BaseSettings):
TOKEN_TIME: int = 2 # Time in Hours
- # Not Used!
- SFTP_USERNAME: Optional[str]
- SFTP_PASSWORD: Optional[str]
-
# Recipe Default Settings
RECIPE_PUBLIC: bool = True
RECIPE_SHOW_NUTRITION: bool = True
@@ -168,6 +164,31 @@ class AppSettings(BaseSettings):
RECIPE_DISABLE_COMMENTS: bool = False
RECIPE_DISABLE_AMOUNT: bool = False
+ # ===============================================
+ # Email Configuration
+ SMTP_HOST: Optional[str]
+ SMTP_PORT: Optional[str] = "587"
+ SMTP_FROM_NAME: Optional[str] = "Mealie"
+ SMTP_TLS: Optional[bool] = True
+ SMTP_FROM_EMAIL: Optional[str]
+ SMTP_USER: Optional[str]
+ SMTP_PASSWORD: Optional[str]
+
+ @property
+ def SMTP_ENABLE(self) -> bool:
+ """Validates all SMTP variables are set"""
+ required = {
+ self.SMTP_HOST,
+ self.SMTP_PORT,
+ self.SMTP_FROM_NAME,
+ self.SMTP_TLS,
+ self.SMTP_FROM_EMAIL,
+ self.SMTP_USER,
+ self.SMTP_PASSWORD,
+ }
+
+ return "" not in required and None not in required
+
class Config:
env_file = BASE_DIR.joinpath(".env")
env_file_encoding = "utf-8"
diff --git a/mealie/routes/admin/__init__.py b/mealie/routes/admin/__init__.py
index f49dbbeca799..c71f49a52f70 100644
--- a/mealie/routes/admin/__init__.py
+++ b/mealie/routes/admin/__init__.py
@@ -1,9 +1,12 @@
from fastapi import APIRouter
-from . import admin_about, admin_group, admin_log
+from mealie.routes.routers import AdminAPIRouter
-router = APIRouter(prefix="/admin")
+from . import admin_about, admin_email, admin_group, admin_log
+
+router = AdminAPIRouter(prefix="/admin")
router.include_router(admin_about.router, tags=["Admin: About"])
router.include_router(admin_log.router, tags=["Admin: Log"])
router.include_router(admin_group.router, tags=["Admin: Group"])
+router.include_router(admin_email.router, tags=["Admin: Email"])
diff --git a/mealie/routes/admin/admin_email.py b/mealie/routes/admin/admin_email.py
new file mode 100644
index 000000000000..1485d8d8a42b
--- /dev/null
+++ b/mealie/routes/admin/admin_email.py
@@ -0,0 +1,47 @@
+from fastapi import APIRouter
+from fastapi_camelcase import CamelModel
+
+from mealie.core.config import get_settings
+from mealie.core.root_logger import get_logger
+from mealie.services.email import EmailService
+
+logger = get_logger(__name__)
+
+router = APIRouter(prefix="/email")
+
+
+class EmailReady(CamelModel):
+ ready: bool
+
+
+class EmailSuccess(CamelModel):
+ success: bool
+ error: str = None
+
+
+class EmailTest(CamelModel):
+ email: str
+
+
+@router.get("", response_model=EmailReady)
+async def check_email_config():
+ """ Get general application information """
+ settings = get_settings()
+
+ return EmailReady(ready=settings.SMTP_ENABLE)
+
+
+@router.post("", response_model=EmailSuccess)
+async def send_test_email(data: EmailTest):
+ print(data)
+ service = EmailService()
+ status = False
+ error = None
+
+ try:
+ status = service.send_test_email(data.email)
+ except Exception as e:
+ logger.error(e)
+ error = str(e)
+
+ return EmailSuccess(success=status, error=error)
diff --git a/mealie/services/email/__init__.py b/mealie/services/email/__init__.py
new file mode 100644
index 000000000000..2cf20aec7f44
--- /dev/null
+++ b/mealie/services/email/__init__.py
@@ -0,0 +1 @@
+from .email_service import EmailService, EmailTemplate
diff --git a/mealie/services/email/email_senders.py b/mealie/services/email/email_senders.py
new file mode 100644
index 000000000000..c7c3f81bf4ac
--- /dev/null
+++ b/mealie/services/email/email_senders.py
@@ -0,0 +1,35 @@
+from abc import ABC, abstractmethod
+
+import emails
+
+from mealie.core.root_logger import get_logger
+from mealie.services._base_service import BaseService
+
+logger = get_logger()
+
+
+class ABCEmailSender(ABC):
+ @abstractmethod
+ def send(self, email_to: str, subject: str, html: str) -> bool:
+ ...
+
+
+class DefaultEmailSender(ABCEmailSender, BaseService):
+ def send(self, email_to: str, subject: str, html: str) -> bool:
+ message = emails.Message(
+ subject=subject,
+ html=html,
+ mail_from=(self.settings.SMTP_FROM_NAME, self.settings.SMTP_FROM_EMAIL),
+ )
+
+ smtp_options = {"host": self.settings.SMTP_HOST, "port": self.settings.SMTP_PORT}
+ if self.settings.SMTP_TLS:
+ smtp_options["tls"] = True
+ if self.settings.SMTP_USER:
+ smtp_options["user"] = self.settings.SMTP_USER
+ if self.settings.SMTP_PASSWORD:
+ smtp_options["password"] = self.settings.SMTP_PASSWORD
+ response = message.send(to=email_to, smtp=smtp_options)
+ logger.info(f"send email result: {response}")
+
+ return response.status_code in [250]
diff --git a/mealie/services/email/email_service.py b/mealie/services/email/email_service.py
new file mode 100644
index 000000000000..2baf79ffe992
--- /dev/null
+++ b/mealie/services/email/email_service.py
@@ -0,0 +1,86 @@
+from pathlib import Path
+
+from jinja2 import Template
+from pydantic import BaseModel
+
+from mealie.core.root_logger import get_logger
+from mealie.services._base_service import BaseService
+
+from .email_senders import ABCEmailSender, DefaultEmailSender
+
+CWD = Path(__file__).parent
+
+logger = get_logger()
+
+
+class EmailTemplate(BaseModel):
+ subject: str
+ header_text: str
+ message_top: str
+ message_bottom: str
+ button_link: str
+ button_text: str
+
+ def render_html(self, template: Path) -> str:
+ tmpl = Template(template.read_text())
+
+ return tmpl.render(data=self.dict())
+
+
+class EmailService(BaseService):
+ def __init__(self, sender: ABCEmailSender = None) -> None:
+ self.templates_dir = CWD / "templates"
+ self.default_template = self.templates_dir / "default.html"
+ self.sender: ABCEmailSender = sender or DefaultEmailSender()
+
+ super().__init__()
+
+ def send_email(self, email_to: str, data: EmailTemplate) -> bool:
+ if not self.settings.SMTP_ENABLE:
+ return False
+
+ return self.sender.send(email_to, data.subject, data.render_html(self.default_template))
+
+ def send_forgot_password(self, address: str, reset_password_url: str) -> bool:
+ forgot_password = EmailTemplate(
+ subject="Mealie Forgot Password",
+ header_text="Forgot Password",
+ message_top="You have requested to reset your password.",
+ message_bottom="Please click the button below to reset your password.",
+ button_link=reset_password_url,
+ button_text="Reset Password",
+ )
+ return self.send_email(address, forgot_password)
+
+ def send_invitation(self, address: str, invitation_url: str) -> bool:
+ invitation = EmailTemplate(
+ subject="Invitation to join Mealie",
+ header_text="Invitation",
+ message_top="You have been invited to join Mealie.",
+ message_bottom="Please click the button below to accept the invitation.",
+ button_link=invitation_url,
+ button_text="Accept Invitation",
+ )
+ return self.send_email(address, invitation)
+
+ def send_test_email(self, address: str) -> bool:
+ test_email = EmailTemplate(
+ subject="Test Email",
+ header_text="Test Email",
+ message_top="This is a test email.",
+ message_bottom="Please click the button below to test the email.",
+ button_link="https://www.google.com",
+ button_text="Test Email",
+ )
+ return self.send_email(address, test_email)
+
+
+def main():
+ print("Starting...")
+ service = EmailService()
+ service.send_test_email("hay-kot@pm.me")
+ print("Finished...")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/mealie/services/email/templates/default.html b/mealie/services/email/templates/default.html
new file mode 100644
index 000000000000..091b7ae1966d
--- /dev/null
+++ b/mealie/services/email/templates/default.html
@@ -0,0 +1,544 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ data.header_text }}
+
+ |
+
+
+
+
+
+
+ Hi there!
+
+
+
+ {{ data.message_top }}
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+ {{ data.bottom_message}}
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+
+
+
diff --git a/poetry.lock b/poetry.lock
index 7d6c5fcfb86a..bb54912ee9f6 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -153,6 +153,14 @@ typing-extensions = ">=3.7.4"
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.3.2)", "aiohttp-cors"]
+[[package]]
+name = "cachetools"
+version = "4.2.4"
+description = "Extensible memoizing collections and decorators"
+category = "main"
+optional = false
+python-versions = "~=3.5"
+
[[package]]
name = "certifi"
version = "2021.5.30"
@@ -172,6 +180,14 @@ python-versions = "*"
[package.dependencies]
pycparser = "*"
+[[package]]
+name = "chardet"
+version = "4.0.0"
+description = "Universal encoding detector for Python 2 and 3"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
+
[[package]]
name = "charset-normalizer"
version = "2.0.6"
@@ -240,6 +256,26 @@ sdist = ["setuptools-rust (>=0.11.4)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["pytest (>=6.0)", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"]
+[[package]]
+name = "cssselect"
+version = "1.1.0"
+description = "cssselect parses CSS3 Selectors and translates them to XPath 1.0"
+category = "main"
+optional = false
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[[package]]
+name = "cssutils"
+version = "2.3.0"
+description = "A CSS Cascading Style Sheets library for Python"
+category = "main"
+optional = false
+python-versions = ">=3.6"
+
+[package.extras]
+docs = ["sphinx", "jaraco.packaging (>=8.2)", "rst.linker (>=1.9)"]
+testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytest-cov", "pytest-enabler (>=1.0.1)", "mock", "lxml", "cssselect", "pytest-black (>=0.3.7)", "pytest-mypy", "importlib-resources"]
+
[[package]]
name = "decorator"
version = "5.1.0"
@@ -263,6 +299,22 @@ six = ">=1.9.0"
gmpy = ["gmpy"]
gmpy2 = ["gmpy2"]
+[[package]]
+name = "emails"
+version = "0.6"
+description = "Modern python library for emails."
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+chardet = "*"
+cssutils = "*"
+lxml = "*"
+premailer = "*"
+python-dateutil = "*"
+requests = "*"
+
[[package]]
name = "extruct"
version = "0.12.0"
@@ -707,6 +759,25 @@ python-versions = ">=3.6"
dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"]
+[[package]]
+name = "premailer"
+version = "3.10.0"
+description = "Turns CSS blocks into style attributes"
+category = "main"
+optional = false
+python-versions = "*"
+
+[package.dependencies]
+cachetools = "*"
+cssselect = "*"
+cssutils = "*"
+lxml = "*"
+requests = "*"
+
+[package.extras]
+dev = ["tox", "twine", "therapist", "black", "flake8", "wheel"]
+test = ["nose", "mock"]
+
[[package]]
name = "psycopg2-binary"
version = "2.9.1"
@@ -887,7 +958,7 @@ testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtuale
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
-category = "dev"
+category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
@@ -1334,7 +1405,7 @@ pgsql = ["psycopg2-binary"]
[metadata]
lock-version = "1.1"
python-versions = "^3.9"
-content-hash = "1b9a18e7114a8f157226c20e951dce0bd08ac884e0795f0f816e9f57d72ec309"
+content-hash = "c030cae2012cedbcad514df8f63a79288d0390d211cfdf4f5a6489a11c96d923"
[metadata.files]
aiofiles = [
@@ -1385,6 +1456,10 @@ beautifulsoup4 = [
black = [
{file = "black-20.8b1.tar.gz", hash = "sha256:1c02557aa099101b9d21496f8a914e9ed2222ef70336404eeeac8edba836fbea"},
]
+cachetools = [
+ {file = "cachetools-4.2.4-py3-none-any.whl", hash = "sha256:92971d3cb7d2a97efff7c7bb1657f21a8f5fb309a37530537c71b1774189f2d1"},
+ {file = "cachetools-4.2.4.tar.gz", hash = "sha256:89ea6f1b638d5a73a4f9226be57ac5e4f399d22770b92355f92dcb0f7f001693"},
+]
certifi = [
{file = "certifi-2021.5.30-py2.py3-none-any.whl", hash = "sha256:50b1e4f8446b06f41be7dd6338db18e0990601dce795c2b1686458aa7e8fa7d8"},
{file = "certifi-2021.5.30.tar.gz", hash = "sha256:2bbf76fd432960138b3ef6dda3dde0544f27cbf8546c458e60baf371917ba9ee"},
@@ -1436,6 +1511,10 @@ cffi = [
{file = "cffi-1.14.6-cp39-cp39-win_amd64.whl", hash = "sha256:818014c754cd3dba7229c0f5884396264d51ffb87ec86e927ef0be140bfdb0d2"},
{file = "cffi-1.14.6.tar.gz", hash = "sha256:c9a875ce9d7fe32887784274dd533c57909b7b1dcadcc128a2ac21331a9765dd"},
]
+chardet = [
+ {file = "chardet-4.0.0-py2.py3-none-any.whl", hash = "sha256:f864054d66fd9118f2e67044ac8981a54775ec5b67aed0441892edb553d21da5"},
+ {file = "chardet-4.0.0.tar.gz", hash = "sha256:0d6f53a15db4120f2b08c94f11e7d93d2c911ee118b6b30a04ec3ee8310179fa"},
+]
charset-normalizer = [
{file = "charset-normalizer-2.0.6.tar.gz", hash = "sha256:5ec46d183433dcbd0ab716f2d7f29d8dee50505b3fdb40c6b985c7c4f5a3591f"},
{file = "charset_normalizer-2.0.6-py3-none-any.whl", hash = "sha256:5d209c0a931f215cee683b6445e2d77677e7e75e159f78def0db09d68fafcaa6"},
@@ -1525,6 +1604,14 @@ cryptography = [
{file = "cryptography-3.4.8-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:cd65b60cfe004790c795cc35f272e41a3df4631e2fb6b35aa7ac6ef2859d554e"},
{file = "cryptography-3.4.8.tar.gz", hash = "sha256:94cc5ed4ceaefcbe5bf38c8fba6a21fc1d365bb8fb826ea1688e3370b2e24a1c"},
]
+cssselect = [
+ {file = "cssselect-1.1.0-py2.py3-none-any.whl", hash = "sha256:f612ee47b749c877ebae5bb77035d8f4202c6ad0f0fc1271b3c18ad6c4468ecf"},
+ {file = "cssselect-1.1.0.tar.gz", hash = "sha256:f95f8dedd925fd8f54edb3d2dfb44c190d9d18512377d3c1e2388d16126879bc"},
+]
+cssutils = [
+ {file = "cssutils-2.3.0-py3-none-any.whl", hash = "sha256:0cf1f6086b020dee18048ff3999339499f725934017ef9ae2cd5bb77f9ab5f46"},
+ {file = "cssutils-2.3.0.tar.gz", hash = "sha256:b2d3b16047caae82e5c590036935bafa1b621cf45c2f38885af4be4838f0fd00"},
+]
decorator = [
{file = "decorator-5.1.0-py3-none-any.whl", hash = "sha256:7b12e7c3c6ab203a29e157335e9122cb03de9ab7264b137594103fd4a683b374"},
{file = "decorator-5.1.0.tar.gz", hash = "sha256:e59913af105b9860aa2c8d3272d9de5a56a4e608db9a2f167a8480b323d529a7"},
@@ -1533,6 +1620,10 @@ ecdsa = [
{file = "ecdsa-0.17.0-py2.py3-none-any.whl", hash = "sha256:5cf31d5b33743abe0dfc28999036c849a69d548f994b535e527ee3cb7f3ef676"},
{file = "ecdsa-0.17.0.tar.gz", hash = "sha256:b9f500bb439e4153d0330610f5d26baaf18d17b8ced1bc54410d189385ea68aa"},
]
+emails = [
+ {file = "emails-0.6-py2.py3-none-any.whl", hash = "sha256:72c1e3198075709cc35f67e1b49e2da1a2bc087e9b444073db61a379adfb7f3c"},
+ {file = "emails-0.6.tar.gz", hash = "sha256:a4c2d67ea8b8831967a750d8edc6e77040d7693143fe280e6d2a367d9c36ff88"},
+]
extruct = [
{file = "extruct-0.12.0-py2.py3-none-any.whl", hash = "sha256:42c6c9f50b00aa6c17b5c26b5f1b3a337ebc27b427fafc3714f34ce3bbb16c2f"},
{file = "extruct-0.12.0.tar.gz", hash = "sha256:d4a68bb79d1b85ff36d603a42c2666888bb480191a399a659d9daaf735358276"},
@@ -1876,6 +1967,10 @@ pluggy = [
{file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"},
{file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"},
]
+premailer = [
+ {file = "premailer-3.10.0-py2.py3-none-any.whl", hash = "sha256:021b8196364d7df96d04f9ade51b794d0b77bcc19e998321c515633a2273be1a"},
+ {file = "premailer-3.10.0.tar.gz", hash = "sha256:d1875a8411f5dc92b53ef9f193db6c0f879dc378d618e0ad292723e388bfe4c2"},
+]
psycopg2-binary = [
{file = "psycopg2-binary-2.9.1.tar.gz", hash = "sha256:b0221ca5a9837e040ebf61f48899926b5783668b7807419e4adae8175a31f773"},
{file = "psycopg2_binary-2.9.1-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:c250a7ec489b652c892e4f0a5d122cc14c3780f9f643e1a326754aedf82d9a76"},
diff --git a/pyproject.toml b/pyproject.toml
index 82fd607589ba..3eed9876aa27 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,6 +36,7 @@ apprise = "0.9.3"
recipe-scrapers = "^13.2.7"
psycopg2-binary = {version = "^2.9.1", optional = true}
gunicorn = "^20.1.0"
+emails = "^0.6"
[tool.poetry.dev-dependencies]
pylint = "^2.6.0"
diff --git a/template.env b/template.env
index e53ade848d21..b98cde18f8de 100644
--- a/template.env
+++ b/template.env
@@ -24,8 +24,13 @@ POSTGRES_DB=mealie
TOKEN_TIME=24
# NOT USED
-SFTP_USERNAME=None
-SFTP_PASSWORD=None
+# SMTP_HOST=""
+# SMTP_PORT=""
+# SMTP_FROM_NAME=""
+# SMTP_TLS=""
+# SMTP_FROM_EMAIL=""
+# SMTP_USER=""
+# SMTP_PASSWORD=""
# Default Recipe Settings
RECIPE_PUBLIC=False
diff --git a/tests/unit_tests/test_config.py b/tests/unit_tests/test_config.py
index 3ceb507ce7e4..837e0fc36471 100644
--- a/tests/unit_tests/test_config.py
+++ b/tests/unit_tests/test_config.py
@@ -77,3 +77,19 @@ def test_set_data_dir():
assert determine_data_dir(True) == PROD_DIR
assert determine_data_dir(False) == DEV_DIR
+
+
+def test_smtp_enable(monkeypatch):
+ app_settings = AppSettings()
+ assert app_settings.SMTP_ENABLE is False
+
+ monkeypatch.setenv("SMTP_HOST", "email.mealie.io")
+ monkeypatch.setenv("SMTP_PORT", "587")
+ monkeypatch.setenv("SMTP_TLS", "true")
+ monkeypatch.setenv("SMTP_FROM_NAME", "Mealie")
+ monkeypatch.setenv("SMTP_FROM_EMAIL", "mealie@mealie.io")
+ monkeypatch.setenv("SMTP_USER", "mealie@mealie.io")
+ monkeypatch.setenv("SMTP_PASSWORD", "mealie-password")
+
+ app_settings = AppSettings()
+ assert app_settings.SMTP_ENABLE is True
diff --git a/tests/unit_tests/test_email_service.py b/tests/unit_tests/test_email_service.py
new file mode 100644
index 000000000000..ea15477e31a4
--- /dev/null
+++ b/tests/unit_tests/test_email_service.py
@@ -0,0 +1,65 @@
+import pytest
+
+from mealie.core.config import AppSettings
+from mealie.services.email import EmailService
+from mealie.services.email.email_senders import ABCEmailSender
+
+FAKE_ADDRESS = "my_secret_email@email.com"
+
+SUBJECTS = {"Mealie Forgot Password", "Invitation to join Mealie", "Test Email"}
+
+
+class TestEmailSender(ABCEmailSender):
+ def send(self, email_to: str, subject: str, html: str) -> bool:
+
+ # check email_to:
+ assert email_to == FAKE_ADDRESS
+
+ # check subject:
+ assert subject in SUBJECTS
+
+ # check html is rendered:
+ assert "{{" not in html
+ assert "}}" not in html
+
+ return True
+
+
+def patch_env(monkeypatch):
+ monkeypatch.setenv("SMTP_HOST", "email.mealie.io")
+ monkeypatch.setenv("SMTP_PORT", 587)
+ monkeypatch.setenv("SMTP_TLS", True)
+ monkeypatch.setenv("SMTP_FROM_NAME", "Mealie")
+ monkeypatch.setenv("SMTP_FROM_EMAIL", "mealie@mealie.io")
+ monkeypatch.setenv("SMTP_USER", "mealie@mealie.io")
+ monkeypatch.setenv("SMTP_PASSWORD", "mealie-password")
+
+
+@pytest.fixture()
+def email_service(monkeypatch) -> EmailService:
+ patch_env(monkeypatch)
+ email_service = EmailService(TestEmailSender())
+ email_service.settings = AppSettings()
+ return email_service
+
+
+def test_email_disabled():
+ email_service = EmailService(TestEmailSender())
+ email_service.settings = AppSettings()
+ success = email_service.send_test_email(FAKE_ADDRESS)
+ assert not success
+
+
+def test_test_email(email_service):
+ success = email_service.send_test_email(FAKE_ADDRESS)
+ assert success
+
+
+def test_forgot_password_email(email_service):
+ success = email_service.send_forgot_password(FAKE_ADDRESS, "https://password-url.com")
+ assert success
+
+
+def test_invitation_email(email_service):
+ success = email_service.send_invitation(FAKE_ADDRESS, "https://invitie-url.com")
+ assert success