mirror of
https://github.com/mealie-recipes/mealie.git
synced 2025-12-12 05:15:18 -05:00
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:
@@ -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"
|
||||
|
||||
@@ -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"])
|
||||
|
||||
47
mealie/routes/admin/admin_email.py
Normal file
47
mealie/routes/admin/admin_email.py
Normal 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)
|
||||
1
mealie/services/email/__init__.py
Normal file
1
mealie/services/email/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from .email_service import EmailService, EmailTemplate
|
||||
35
mealie/services/email/email_senders.py
Normal file
35
mealie/services/email/email_senders.py
Normal 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]
|
||||
86
mealie/services/email/email_service.py
Normal file
86
mealie/services/email/email_service.py
Normal 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()
|
||||
544
mealie/services/email/templates/default.html
Normal file
544
mealie/services/email/templates/default.html
Normal 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>
|
||||
Reference in New Issue
Block a user