Feature/email support (#720)

* feat(frontend):  add UI for testing email configuration

* feat(backend):  add email service with common templates (WIP)

* test(backend):  add basic tests for email configuration

* set defaults

* add email variables

Co-authored-by: Hayden <hay-kot@pm.me>
This commit is contained in:
Hayden
2021-10-03 18:38:45 -08:00
committed by GitHub
parent c0dd07f9e7
commit b7b8aa9a08
20 changed files with 1168 additions and 61 deletions

View File

@@ -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"

View File

@@ -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"])

View File

@@ -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)

View File

@@ -0,0 +1 @@
from .email_service import EmailService, EmailTemplate

View File

@@ -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]

View File

@@ -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()

View File

@@ -0,0 +1,544 @@
<!DOCTYPE html>
<html
xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office"
>
<head>
<title> </title>
<!--[if !mso]><!-->
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<!--<![endif]-->
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
#outlook a {
padding: 0;
}
body {
margin: 0;
padding: 0;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
table,
td {
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
img {
border: 0;
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
-ms-interpolation-mode: bicubic;
}
p {
display: block;
margin: 13px 0;
}
</style>
<!--[if mso]>
<noscript>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG />
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
</noscript>
<![endif]-->
<!--[if lte mso 11]>
<style type="text/css">
.mj-outlook-group-fix {
width: 100% !important;
}
</style>
<![endif]-->
<!--[if !mso]><!-->
<link
href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700"
rel="stylesheet"
type="text/css"
/>
<link
href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700"
rel="stylesheet"
type="text/css"
/>
<style type="text/css">
@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500,700);
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
</style>
<!--<![endif]-->
<style type="text/css">
@media only screen and (min-width: 480px) {
.mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
}
</style>
<style media="screen and (min-width:480px)">
.moz-text-html .mj-column-per-100 {
width: 100% !important;
max-width: 100%;
}
</style>
<style type="text/css">
@media only screen and (max-width: 480px) {
table.mj-full-width-mobile {
width: 100% !important;
}
td.mj-full-width-mobile {
width: auto !important;
}
}
noinput.mj-menu-checkbox {
display: block !important;
max-height: none !important;
visibility: visible !important;
}
@media only screen and (max-width: 480px) {
.mj-menu-checkbox[type="checkbox"] ~ .mj-inline-links {
display: none !important;
}
.mj-menu-checkbox[type="checkbox"]:checked ~ .mj-inline-links,
.mj-menu-checkbox[type="checkbox"] ~ .mj-menu-trigger {
display: block !important;
max-width: none !important;
max-height: none !important;
font-size: inherit !important;
}
.mj-menu-checkbox[type="checkbox"] ~ .mj-inline-links > a {
display: block !important;
}
.mj-menu-checkbox[type="checkbox"]:checked
~ .mj-menu-trigger
.mj-menu-icon-close {
display: block !important;
}
.mj-menu-checkbox[type="checkbox"]:checked
~ .mj-menu-trigger
.mj-menu-icon-open {
display: none !important;
}
}
</style>
</head>
<body style="word-spacing: normal">
<div style="">
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 0px;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
>
<tbody>
<tr>
<td style="width: 550px">
<img
height="auto"
src="https://api-test.emailbuilder.top/saemailbuilder/dc23dc82-ffd7-4f4c-b563-94f23db4c2c3/images/256d8bd6-ffde-4bf2-b577-dd8306dae877/file.png"
style="
border: 0;
display: block;
outline: none;
text-decoration: none;
height: auto;
width: 100%;
font-size: 13px;
"
width="550"
/>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
<div style="margin: 0px auto; max-width: 600px">
<table
align="center"
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%"
>
<tbody>
<tr>
<td
style="
direction: ltr;
font-size: 0px;
padding: 20px 0;
text-align: center;
"
>
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
<div
class="mj-column-per-100 mj-outlook-group-fix"
style="
font-size: 0px;
text-align: left;
direction: ltr;
display: inline-block;
vertical-align: top;
width: 100%;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="vertical-align: top"
width="100%"
>
<tbody>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 0px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 20px;
line-height: 1;
text-align: center;
color: #e38333;
"
>
<h2>{{ data.header_text }}</h2>
</div>
</td>
</tr>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 13px;
line-height: 1;
text-align: left;
color: #000000;
"
>
<div style="text-align: center">
<b>Hi there!</b>
</div>
<div><br /></div>
<div style="text-align: center">
{{ data.message_top }}
</div>
<div><br /></div>
</div>
</td>
</tr>
<tr>
<td
align="center"
vertical-align="middle"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="border-collapse: separate; line-height: 100%"
>
<tbody>
<tr>
<td
align="center"
bgcolor="#e38333"
role="presentation"
style="
border: none;
border-radius: 3px;
cursor: auto;
mso-padding-alt: 10px 15px;
background: #e38333;
"
valign="middle"
>
<a
href="{{ data.button_link }}"
style="
display: inline-block;
background: #e38333;
color: #ffffff;
font-family: Ubuntu, Helvetica, Arial,
sans-serif;
font-size: 13px;
font-weight: 700;
line-height: 120%;
margin: 0;
text-decoration: none;
text-transform: none;
padding: 10px 15px;
mso-padding-alt: 0px;
border-radius: 3px;
"
>
{{ data.button_text}}
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td
align="left"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<div
style="
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 10px;
line-height: 1.5;
text-align: left;
color: #000000;
"
>
<div style="text-align: center">
{{ data.bottom_message}}
</div>
</div>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 10px 25px;
word-break: break-word;
"
>
<table
border="0"
cellpadding="0"
cellspacing="0"
role="presentation"
style="
border-collapse: collapse;
border-spacing: 0px;
"
></table>
</td>
</tr>
<tr>
<td
align="center"
style="
font-size: 0px;
padding: 5px 0px;
word-break: break-word;
"
>
<div class="mj-inline-links" style="">
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0" align="center"><tr><td style="padding:15px 10px;" class="" ><![endif]-->
<a
class="mj-link"
href="https://github.com/hay-kot/mealie"
target="_blank"
style="
display: inline-block;
color: #dd8333;
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 13px;
font-weight: normal;
line-height: 22px;
text-decoration: none;
text-transform: uppercase;
padding: 15px 10px;
"
>
Github
</a>
<!--[if mso | IE]></td><td style="padding:15px 10px;" class="" ><![endif]-->
<a
class="mj-link"
href="https://discord.gg/PfByzb5EKH"
target="_blank"
style="
display: inline-block;
color: #dd8333;
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 13px;
font-weight: normal;
line-height: 22px;
text-decoration: none;
text-transform: uppercase;
padding: 15px 10px;
"
>
Discord
</a>
<!--[if mso | IE]></td><td style="padding:15px 10px;" class="" ><![endif]-->
<a
class="mj-link"
href="https://hay-kot.github.io/mealie/"
target="_blank"
style="
display: inline-block;
color: #dd8333;
font-family: Roboto, Helvetica Neue, Helvetica,
Arial, sans-serif;
font-size: 13px;
font-weight: normal;
line-height: 22px;
text-decoration: none;
text-transform: uppercase;
padding: 15px 10px;
"
>
Documentation
</a>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</td>
</tr>
</tbody>
</table>
</div>
<!--[if mso | IE]></td></tr></table><![endif]-->
</div>
</body>
</html>