msaas-mailer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- msaas_mailer-0.1.0/.gitignore +23 -0
- msaas_mailer-0.1.0/PKG-INFO +13 -0
- msaas_mailer-0.1.0/pyproject.toml +32 -0
- msaas_mailer-0.1.0/src/mailer/__init__.py +17 -0
- msaas_mailer-0.1.0/src/mailer/config.py +44 -0
- msaas_mailer-0.1.0/src/mailer/sender.py +121 -0
- msaas_mailer-0.1.0/src/mailer/templates/__init__.py +100 -0
- msaas_mailer-0.1.0/src/mailer/templates/alert.html +83 -0
- msaas_mailer-0.1.0/src/mailer/templates/base.html +116 -0
- msaas_mailer-0.1.0/src/mailer/templates/digest.html +70 -0
- msaas_mailer-0.1.0/src/mailer/templates/invoice.html +74 -0
- msaas_mailer-0.1.0/src/mailer/templates/password_reset.html +53 -0
- msaas_mailer-0.1.0/src/mailer/templates/welcome.html +55 -0
- msaas_mailer-0.1.0/tests/__init__.py +0 -0
- msaas_mailer-0.1.0/tests/conftest.py +37 -0
- msaas_mailer-0.1.0/tests/test_sender.py +282 -0
- msaas_mailer-0.1.0/tests/test_templates.py +378 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
node_modules/
|
|
2
|
+
dist/
|
|
3
|
+
.next/
|
|
4
|
+
.turbo/
|
|
5
|
+
*.pyc
|
|
6
|
+
__pycache__/
|
|
7
|
+
.venv/
|
|
8
|
+
*.egg-info/
|
|
9
|
+
.pytest_cache/
|
|
10
|
+
.ruff_cache/
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
!.env.*.example
|
|
15
|
+
!.env.*.template
|
|
16
|
+
.DS_Store
|
|
17
|
+
coverage/
|
|
18
|
+
|
|
19
|
+
# Runtime artifacts
|
|
20
|
+
logs_llm/
|
|
21
|
+
vectors.db
|
|
22
|
+
vectors.db-shm
|
|
23
|
+
vectors.db-wal
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: msaas-mailer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Transactional email module with Resend and pre-built templates
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Requires-Dist: jinja2>=3.1.0
|
|
7
|
+
Requires-Dist: msaas-api-core
|
|
8
|
+
Requires-Dist: msaas-errors
|
|
9
|
+
Requires-Dist: pydantic>=2.0
|
|
10
|
+
Requires-Dist: resend>=2.0.0
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "msaas-mailer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Transactional email module with Resend and pre-built templates"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"msaas-api-core",
|
|
8
|
+
"msaas-errors",
|
|
9
|
+
"resend>=2.0.0",
|
|
10
|
+
"pydantic>=2.0",
|
|
11
|
+
"jinja2>=3.1.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
dev = [
|
|
16
|
+
"pytest>=8.0",
|
|
17
|
+
"pytest-asyncio>=0.23",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[build-system]
|
|
21
|
+
requires = ["hatchling"]
|
|
22
|
+
build-backend = "hatchling.build"
|
|
23
|
+
|
|
24
|
+
[tool.hatch.build.targets.wheel]
|
|
25
|
+
packages = ["src/mailer"]
|
|
26
|
+
|
|
27
|
+
[tool.pytest.ini_options]
|
|
28
|
+
testpaths = ["tests"]
|
|
29
|
+
|
|
30
|
+
[tool.uv.sources]
|
|
31
|
+
msaas-api-core = { workspace = true }
|
|
32
|
+
msaas-errors = { workspace = true }
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Willian Email — Transactional email module with Resend and pre-built templates."""
|
|
2
|
+
|
|
3
|
+
from mailer.config import EmailConfig, init_email
|
|
4
|
+
from mailer.sender import Recipient, SendResult, send_bulk, send_email
|
|
5
|
+
from mailer.templates import EmailTemplate, register_template, render_template
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EmailConfig",
|
|
9
|
+
"EmailTemplate",
|
|
10
|
+
"Recipient",
|
|
11
|
+
"SendResult",
|
|
12
|
+
"init_email",
|
|
13
|
+
"register_template",
|
|
14
|
+
"render_template",
|
|
15
|
+
"send_bulk",
|
|
16
|
+
"send_email",
|
|
17
|
+
]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Email module configuration and initialization."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
# Global config singleton — set via init_email()
|
|
8
|
+
_config: EmailConfig | None = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EmailConfig(BaseModel):
|
|
12
|
+
"""Configuration for the email module."""
|
|
13
|
+
|
|
14
|
+
api_key: str
|
|
15
|
+
default_from: str
|
|
16
|
+
reply_to: str | None = None
|
|
17
|
+
product_name: str
|
|
18
|
+
product_url: str
|
|
19
|
+
logo_url: str | None = None
|
|
20
|
+
support_email: str | None = None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def init_email(config: EmailConfig) -> None:
|
|
24
|
+
"""Initialize the email module with the given configuration.
|
|
25
|
+
|
|
26
|
+
Must be called before send_email() or send_bulk(). Configures the Resend
|
|
27
|
+
SDK and stores settings used by templates (product name, logo, etc.).
|
|
28
|
+
"""
|
|
29
|
+
import resend
|
|
30
|
+
|
|
31
|
+
global _config
|
|
32
|
+
_config = config
|
|
33
|
+
resend.api_key = config.api_key
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_config() -> EmailConfig:
|
|
37
|
+
"""Return the current module configuration.
|
|
38
|
+
|
|
39
|
+
Raises:
|
|
40
|
+
RuntimeError: If init_email() has not been called.
|
|
41
|
+
"""
|
|
42
|
+
if _config is None:
|
|
43
|
+
raise RuntimeError("Email module not initialized. Call init_email() first.")
|
|
44
|
+
return _config
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Email sending via Resend SDK."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
import resend
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from mailer.config import get_config
|
|
12
|
+
from mailer.templates import render_template
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SendResult(BaseModel):
|
|
18
|
+
"""Result of a single email send operation."""
|
|
19
|
+
|
|
20
|
+
id: str | None = None
|
|
21
|
+
status: Literal["sent", "failed"]
|
|
22
|
+
error: str | None = None
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Recipient(BaseModel):
|
|
26
|
+
"""A single recipient for bulk email operations."""
|
|
27
|
+
|
|
28
|
+
email: str
|
|
29
|
+
name: str | None = None
|
|
30
|
+
subject: str
|
|
31
|
+
data: dict = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def send_email(
|
|
35
|
+
to: str | list[str],
|
|
36
|
+
subject: str,
|
|
37
|
+
template: str,
|
|
38
|
+
data: dict | None = None,
|
|
39
|
+
from_addr: str | None = None,
|
|
40
|
+
) -> SendResult:
|
|
41
|
+
"""Send a transactional email using a named template.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
to: Recipient email address or list of addresses.
|
|
45
|
+
subject: Email subject line.
|
|
46
|
+
template: Template name (e.g. "welcome", "invoice").
|
|
47
|
+
data: Variables passed to the Jinja2 template.
|
|
48
|
+
from_addr: Override the default sender address.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
SendResult with delivery id and status.
|
|
52
|
+
"""
|
|
53
|
+
config = get_config()
|
|
54
|
+
template_data = {**(data or {})}
|
|
55
|
+
|
|
56
|
+
# Inject global template variables from config
|
|
57
|
+
template_data.setdefault("product_name", config.product_name)
|
|
58
|
+
template_data.setdefault("product_url", config.product_url)
|
|
59
|
+
template_data.setdefault("logo_url", config.logo_url)
|
|
60
|
+
template_data.setdefault("support_email", config.support_email)
|
|
61
|
+
|
|
62
|
+
html = render_template(template, template_data)
|
|
63
|
+
|
|
64
|
+
params: resend.Emails.SendParams = {
|
|
65
|
+
"from_": from_addr or config.default_from,
|
|
66
|
+
"to": [to] if isinstance(to, str) else to,
|
|
67
|
+
"subject": subject,
|
|
68
|
+
"html": html,
|
|
69
|
+
}
|
|
70
|
+
if config.reply_to:
|
|
71
|
+
params["reply_to"] = config.reply_to
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
response = resend.Emails.send(params)
|
|
75
|
+
email_id = (
|
|
76
|
+
response.get("id") if isinstance(response, dict) else getattr(response, "id", None)
|
|
77
|
+
)
|
|
78
|
+
logger.info("Email sent successfully", extra={"email_id": email_id, "to": to})
|
|
79
|
+
return SendResult(id=email_id, status="sent")
|
|
80
|
+
except Exception as exc:
|
|
81
|
+
logger.error("Failed to send email", extra={"error": str(exc), "to": to})
|
|
82
|
+
return SendResult(status="failed", error=str(exc))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def send_bulk(
|
|
86
|
+
recipients: list[Recipient],
|
|
87
|
+
template: str,
|
|
88
|
+
) -> list[SendResult]:
|
|
89
|
+
"""Send the same template to multiple recipients with individual data.
|
|
90
|
+
|
|
91
|
+
Each recipient gets their own rendered email. This does NOT use Resend
|
|
92
|
+
batch API — it sends individually so each recipient can have unique
|
|
93
|
+
template data.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
recipients: List of Recipient objects with email, subject, and data.
|
|
97
|
+
template: Template name to render for each recipient.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
List of SendResult objects, one per recipient (same order).
|
|
101
|
+
"""
|
|
102
|
+
results: list[SendResult] = []
|
|
103
|
+
for recipient in recipients:
|
|
104
|
+
merged_data = {**recipient.data}
|
|
105
|
+
if recipient.name:
|
|
106
|
+
merged_data.setdefault("user_name", recipient.name)
|
|
107
|
+
|
|
108
|
+
result = send_email(
|
|
109
|
+
to=recipient.email,
|
|
110
|
+
subject=recipient.subject,
|
|
111
|
+
template=template,
|
|
112
|
+
data=merged_data,
|
|
113
|
+
)
|
|
114
|
+
results.append(result)
|
|
115
|
+
|
|
116
|
+
sent = sum(1 for r in results if r.status == "sent")
|
|
117
|
+
logger.info(
|
|
118
|
+
"Bulk send complete",
|
|
119
|
+
extra={"total": len(recipients), "sent": sent, "failed": len(recipients) - sent},
|
|
120
|
+
)
|
|
121
|
+
return results
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Template rendering engine for email templates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import NamedTuple
|
|
7
|
+
|
|
8
|
+
from jinja2 import Environment, FileSystemLoader, select_autoescape
|
|
9
|
+
|
|
10
|
+
# Directory containing built-in HTML templates
|
|
11
|
+
_TEMPLATE_DIR = Path(__file__).parent
|
|
12
|
+
|
|
13
|
+
# Custom templates registered at runtime
|
|
14
|
+
_custom_templates: dict[str, str] = {}
|
|
15
|
+
|
|
16
|
+
# Jinja2 environment — lazy-initialized
|
|
17
|
+
_env: Environment | None = None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EmailTemplate(NamedTuple):
|
|
21
|
+
"""Metadata about a registered template."""
|
|
22
|
+
|
|
23
|
+
name: str
|
|
24
|
+
source: str # "builtin" or "custom"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _get_env() -> Environment:
|
|
28
|
+
"""Return the Jinja2 environment, creating it on first use."""
|
|
29
|
+
global _env
|
|
30
|
+
if _env is None:
|
|
31
|
+
_env = Environment(
|
|
32
|
+
loader=FileSystemLoader(str(_TEMPLATE_DIR)),
|
|
33
|
+
autoescape=select_autoescape(["html"]),
|
|
34
|
+
trim_blocks=True,
|
|
35
|
+
lstrip_blocks=True,
|
|
36
|
+
)
|
|
37
|
+
return _env
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def render_template(template_name: str, data: dict) -> str:
|
|
41
|
+
"""Render a named template with the given data.
|
|
42
|
+
|
|
43
|
+
Looks up custom templates first, then falls back to built-in
|
|
44
|
+
templates from the templates directory.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
template_name: Name of the template (without .html extension).
|
|
48
|
+
data: Variables to pass to the Jinja2 template.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Rendered HTML string.
|
|
52
|
+
|
|
53
|
+
Raises:
|
|
54
|
+
jinja2.TemplateNotFound: If no template with that name exists.
|
|
55
|
+
"""
|
|
56
|
+
env = _get_env()
|
|
57
|
+
|
|
58
|
+
# Check custom templates first
|
|
59
|
+
if template_name in _custom_templates:
|
|
60
|
+
tmpl = env.from_string(_custom_templates[template_name])
|
|
61
|
+
return tmpl.render(**data)
|
|
62
|
+
|
|
63
|
+
# Fall back to file-based template
|
|
64
|
+
tmpl = env.get_template(f"{template_name}.html")
|
|
65
|
+
return tmpl.render(**data)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def register_template(name: str, html: str) -> None:
|
|
69
|
+
"""Register a custom template at runtime.
|
|
70
|
+
|
|
71
|
+
Custom templates take priority over built-in file-based templates
|
|
72
|
+
with the same name.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
name: Template name (used when calling send_email).
|
|
76
|
+
html: Raw HTML/Jinja2 template string.
|
|
77
|
+
"""
|
|
78
|
+
_custom_templates[name] = html
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def list_templates() -> list[EmailTemplate]:
|
|
82
|
+
"""List all available templates (built-in and custom).
|
|
83
|
+
|
|
84
|
+
Returns:
|
|
85
|
+
List of EmailTemplate named tuples.
|
|
86
|
+
"""
|
|
87
|
+
templates: list[EmailTemplate] = []
|
|
88
|
+
|
|
89
|
+
# Built-in templates from filesystem
|
|
90
|
+
for path in sorted(_TEMPLATE_DIR.glob("*.html")):
|
|
91
|
+
if path.name == "base.html":
|
|
92
|
+
continue
|
|
93
|
+
name = path.stem
|
|
94
|
+
templates.append(EmailTemplate(name=name, source="builtin"))
|
|
95
|
+
|
|
96
|
+
# Custom templates
|
|
97
|
+
for name in sorted(_custom_templates):
|
|
98
|
+
templates.append(EmailTemplate(name=name, source="custom"))
|
|
99
|
+
|
|
100
|
+
return templates
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block preheader %}{{ severity | upper }}: {{ alert_title }}{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<!-- Severity banner -->
|
|
7
|
+
{% if severity == "error" %}
|
|
8
|
+
{% set banner_bg = "#fef2f2" %}
|
|
9
|
+
{% set banner_border = "#fca5a5" %}
|
|
10
|
+
{% set banner_text = "#991b1b" %}
|
|
11
|
+
{% set banner_icon = "!" %}
|
|
12
|
+
{% elif severity == "warning" %}
|
|
13
|
+
{% set banner_bg = "#fffbeb" %}
|
|
14
|
+
{% set banner_border = "#fcd34d" %}
|
|
15
|
+
{% set banner_text = "#92400e" %}
|
|
16
|
+
{% set banner_icon = "!" %}
|
|
17
|
+
{% else %}
|
|
18
|
+
{% set banner_bg = "#eff6ff" %}
|
|
19
|
+
{% set banner_border = "#93c5fd" %}
|
|
20
|
+
{% set banner_text = "#1e40af" %}
|
|
21
|
+
{% set banner_icon = "i" %}
|
|
22
|
+
{% endif %}
|
|
23
|
+
|
|
24
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 0 28px 0;">
|
|
25
|
+
<tr>
|
|
26
|
+
<td style="background-color: {{ banner_bg }}; border-left: 4px solid {{ banner_border }}; border-radius: 0 8px 8px 0; padding: 16px 20px;">
|
|
27
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
28
|
+
<tr>
|
|
29
|
+
<td style="width: 32px; vertical-align: top; padding-right: 12px;">
|
|
30
|
+
<div style="width: 28px; height: 28px; border-radius: 50%; background-color: {{ banner_border }}; text-align: center; line-height: 28px; font-size: 14px; font-weight: 700; color: {{ banner_text }};">
|
|
31
|
+
{{ banner_icon }}
|
|
32
|
+
</div>
|
|
33
|
+
</td>
|
|
34
|
+
<td style="vertical-align: top;">
|
|
35
|
+
<p style="margin: 0 0 4px 0; font-size: 15px; font-weight: 700; color: {{ banner_text }}; text-transform: uppercase; letter-spacing: 0.3px;">
|
|
36
|
+
{{ severity | upper }}
|
|
37
|
+
</p>
|
|
38
|
+
<p style="margin: 0; font-size: 18px; font-weight: 700; color: {{ banner_text }}; line-height: 1.3;">
|
|
39
|
+
{{ alert_title }}
|
|
40
|
+
</p>
|
|
41
|
+
</td>
|
|
42
|
+
</tr>
|
|
43
|
+
</table>
|
|
44
|
+
</td>
|
|
45
|
+
</tr>
|
|
46
|
+
</table>
|
|
47
|
+
|
|
48
|
+
<!-- Greeting -->
|
|
49
|
+
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 1.6; color: #4a4a5a;">
|
|
50
|
+
{% if user_name %}Hi {{ user_name }},{% else %}Hi there,{% endif %}
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<!-- Alert message -->
|
|
54
|
+
<p style="margin: 0 0 28px 0; font-size: 16px; line-height: 1.6; color: #4a4a5a;">
|
|
55
|
+
{{ alert_message }}
|
|
56
|
+
</p>
|
|
57
|
+
|
|
58
|
+
<!-- Optional action button -->
|
|
59
|
+
{% if action_url and action_text %}
|
|
60
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="100%">
|
|
61
|
+
<tr>
|
|
62
|
+
<td style="text-align: center; padding: 4px 0 16px 0;">
|
|
63
|
+
{% if severity == "error" %}
|
|
64
|
+
{% set btn_color = "#dc2626" %}
|
|
65
|
+
{% elif severity == "warning" %}
|
|
66
|
+
{% set btn_color = "#d97706" %}
|
|
67
|
+
{% else %}
|
|
68
|
+
{% set btn_color = "#6366f1" %}
|
|
69
|
+
{% endif %}
|
|
70
|
+
<a href="{{ action_url }}" style="display: inline-block; background-color: {{ btn_color }}; color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; padding: 14px 36px; border-radius: 8px; mso-padding-alt: 0; text-align: center;">
|
|
71
|
+
<!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%; mso-text-raise: 26pt;"> </i><![endif]-->
|
|
72
|
+
<span style="mso-text-raise: 13pt;">{{ action_text }}</span>
|
|
73
|
+
<!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%;"> </i><![endif]-->
|
|
74
|
+
</a>
|
|
75
|
+
</td>
|
|
76
|
+
</tr>
|
|
77
|
+
</table>
|
|
78
|
+
{% endif %}
|
|
79
|
+
|
|
80
|
+
<p style="margin: 16px 0 0 0; font-size: 14px; line-height: 1.5; color: #8c8c9a; text-align: center;">
|
|
81
|
+
This is an automated alert from {{ product_name }}. If you believe this was sent in error, please contact support.
|
|
82
|
+
</p>
|
|
83
|
+
{% endblock %}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
|
7
|
+
<meta name="x-apple-disable-message-reformatting">
|
|
8
|
+
<title>{% block title %}{{ product_name }}{% endblock %}</title>
|
|
9
|
+
<!--[if mso]>
|
|
10
|
+
<noscript>
|
|
11
|
+
<xml>
|
|
12
|
+
<o:OfficeDocumentSettings>
|
|
13
|
+
<o:PixelsPerInch>96</o:PixelsPerInch>
|
|
14
|
+
</o:OfficeDocumentSettings>
|
|
15
|
+
</xml>
|
|
16
|
+
</noscript>
|
|
17
|
+
<![endif]-->
|
|
18
|
+
<style>
|
|
19
|
+
/* Reset */
|
|
20
|
+
body, table, td, a { -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%; }
|
|
21
|
+
table, td { mso-table-lspace: 0pt; mso-table-rspace: 0pt; }
|
|
22
|
+
img { -ms-interpolation-mode: bicubic; border: 0; height: auto; line-height: 100%; outline: none; text-decoration: none; }
|
|
23
|
+
body { height: 100% !important; margin: 0 !important; padding: 0 !important; width: 100% !important; }
|
|
24
|
+
a[x-apple-data-detectors] { color: inherit !important; text-decoration: none !important; font-size: inherit !important; font-family: inherit !important; font-weight: inherit !important; line-height: inherit !important; }
|
|
25
|
+
|
|
26
|
+
/* Responsive */
|
|
27
|
+
@media only screen and (max-width: 620px) {
|
|
28
|
+
.email-container { width: 100% !important; max-width: 100% !important; }
|
|
29
|
+
.fluid { max-width: 100% !important; height: auto !important; margin-left: auto !important; margin-right: auto !important; }
|
|
30
|
+
.stack-column { display: block !important; width: 100% !important; max-width: 100% !important; }
|
|
31
|
+
.center-on-narrow { text-align: center !important; display: block !important; margin-left: auto !important; margin-right: auto !important; float: none !important; }
|
|
32
|
+
table.center-on-narrow { display: inline-block !important; }
|
|
33
|
+
.padding-mobile { padding-left: 20px !important; padding-right: 20px !important; }
|
|
34
|
+
}
|
|
35
|
+
</style>
|
|
36
|
+
</head>
|
|
37
|
+
<body style="margin: 0; padding: 0; background-color: #f4f5f7; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; color: #1a1a2e;">
|
|
38
|
+
|
|
39
|
+
<!-- Preheader (hidden text for email preview) -->
|
|
40
|
+
<div style="display: none; font-size: 1px; line-height: 1px; max-height: 0px; max-width: 0px; opacity: 0; overflow: hidden; mso-hide: all;">
|
|
41
|
+
{% block preheader %}{% endblock %}
|
|
42
|
+
</div>
|
|
43
|
+
|
|
44
|
+
<!-- Full-width wrapper -->
|
|
45
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f5f7;">
|
|
46
|
+
<tr>
|
|
47
|
+
<td style="padding: 40px 10px;">
|
|
48
|
+
|
|
49
|
+
<!-- Email container (max 580px) -->
|
|
50
|
+
<table class="email-container" role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="580" style="max-width: 580px; margin: 0 auto;">
|
|
51
|
+
|
|
52
|
+
<!-- Header -->
|
|
53
|
+
<tr>
|
|
54
|
+
<td style="padding: 0 0 24px 0; text-align: center;">
|
|
55
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center">
|
|
56
|
+
<tr>
|
|
57
|
+
{% if logo_url %}
|
|
58
|
+
<td style="padding-right: 12px; vertical-align: middle;">
|
|
59
|
+
<img src="{{ logo_url }}" alt="{{ product_name }}" width="36" height="36" style="width: 36px; height: 36px; border-radius: 8px;">
|
|
60
|
+
</td>
|
|
61
|
+
{% endif %}
|
|
62
|
+
<td style="vertical-align: middle;">
|
|
63
|
+
<a href="{{ product_url }}" style="font-size: 20px; font-weight: 700; color: #1a1a2e; text-decoration: none; letter-spacing: -0.3px;">{{ product_name }}</a>
|
|
64
|
+
</td>
|
|
65
|
+
</tr>
|
|
66
|
+
</table>
|
|
67
|
+
</td>
|
|
68
|
+
</tr>
|
|
69
|
+
|
|
70
|
+
<!-- Body -->
|
|
71
|
+
<tr>
|
|
72
|
+
<td style="background-color: #ffffff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);">
|
|
73
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
74
|
+
<tr>
|
|
75
|
+
<td class="padding-mobile" style="padding: 40px 48px;">
|
|
76
|
+
{% block content %}{% endblock %}
|
|
77
|
+
</td>
|
|
78
|
+
</tr>
|
|
79
|
+
</table>
|
|
80
|
+
</td>
|
|
81
|
+
</tr>
|
|
82
|
+
|
|
83
|
+
<!-- Footer -->
|
|
84
|
+
<tr>
|
|
85
|
+
<td style="padding: 32px 20px 0 20px; text-align: center;">
|
|
86
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="100%">
|
|
87
|
+
<tr>
|
|
88
|
+
<td style="font-size: 13px; line-height: 20px; color: #8c8c9a; text-align: center;">
|
|
89
|
+
<p style="margin: 0 0 8px 0;">
|
|
90
|
+
© {{ product_name }}. All rights reserved.
|
|
91
|
+
</p>
|
|
92
|
+
{% if support_email %}
|
|
93
|
+
<p style="margin: 0 0 8px 0;">
|
|
94
|
+
Questions? Contact us at <a href="mailto:{{ support_email }}" style="color: #6366f1; text-decoration: none;">{{ support_email }}</a>
|
|
95
|
+
</p>
|
|
96
|
+
{% endif %}
|
|
97
|
+
<p style="margin: 0;">
|
|
98
|
+
<a href="{{ product_url }}/unsubscribe" style="color: #8c8c9a; text-decoration: underline;">Unsubscribe</a>
|
|
99
|
+
·
|
|
100
|
+
<a href="{{ product_url }}/privacy" style="color: #8c8c9a; text-decoration: underline;">Privacy Policy</a>
|
|
101
|
+
</p>
|
|
102
|
+
</td>
|
|
103
|
+
</tr>
|
|
104
|
+
</table>
|
|
105
|
+
</td>
|
|
106
|
+
</tr>
|
|
107
|
+
|
|
108
|
+
</table>
|
|
109
|
+
<!-- /Email container -->
|
|
110
|
+
|
|
111
|
+
</td>
|
|
112
|
+
</tr>
|
|
113
|
+
</table>
|
|
114
|
+
|
|
115
|
+
</body>
|
|
116
|
+
</html>
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{% extends "base.html" %}
|
|
2
|
+
|
|
3
|
+
{% block preheader %}Your {{ period }} digest from {{ product_name }} — {{ items | length }} updates{% endblock %}
|
|
4
|
+
|
|
5
|
+
{% block content %}
|
|
6
|
+
<!-- Heading -->
|
|
7
|
+
<h1 style="margin: 0 0 8px 0; font-size: 26px; font-weight: 700; color: #1a1a2e; line-height: 1.3;">
|
|
8
|
+
Your {{ period }} digest
|
|
9
|
+
</h1>
|
|
10
|
+
|
|
11
|
+
<p style="margin: 0 0 28px 0; font-size: 16px; line-height: 1.6; color: #4a4a5a;">
|
|
12
|
+
{% if user_name %}Hi {{ user_name }},{% else %}Hi there,{% endif %}
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<p style="margin: 0 0 28px 0; font-size: 16px; line-height: 1.6; color: #4a4a5a;">
|
|
16
|
+
{% if summary_text %}
|
|
17
|
+
{{ summary_text }}
|
|
18
|
+
{% else %}
|
|
19
|
+
Here's what happened since your last digest.
|
|
20
|
+
{% endif %}
|
|
21
|
+
</p>
|
|
22
|
+
|
|
23
|
+
<!-- Digest items -->
|
|
24
|
+
{% if items %}
|
|
25
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin: 0 0 32px 0;">
|
|
26
|
+
{% for item in items %}
|
|
27
|
+
<tr>
|
|
28
|
+
<td style="padding: 0 0 {% if not loop.last %}20px{% else %}0{% endif %} 0; border-bottom: {% if not loop.last %}1px solid #f3f4f6{% else %}none{% endif %};">
|
|
29
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
|
|
30
|
+
<tr>
|
|
31
|
+
<td style="padding: 0 16px 0 0; vertical-align: top; width: 36px;">
|
|
32
|
+
<div style="width: 36px; height: 36px; border-radius: 8px; background-color: #eef2ff; text-align: center; line-height: 36px; font-size: 15px; font-weight: 600; color: #6366f1;">
|
|
33
|
+
{{ loop.index }}
|
|
34
|
+
</div>
|
|
35
|
+
</td>
|
|
36
|
+
<td style="vertical-align: top; padding: {% if not loop.last %}0 0 20px 0{% else %}0{% endif %};">
|
|
37
|
+
<p style="margin: 0 0 4px 0;">
|
|
38
|
+
<a href="{{ item.url }}" style="font-size: 16px; font-weight: 600; color: #1a1a2e; text-decoration: none; line-height: 1.4;">
|
|
39
|
+
{{ item.title }}
|
|
40
|
+
</a>
|
|
41
|
+
</p>
|
|
42
|
+
<p style="margin: 0; font-size: 14px; line-height: 1.5; color: #6b7280;">
|
|
43
|
+
{{ item.description }}
|
|
44
|
+
</p>
|
|
45
|
+
</td>
|
|
46
|
+
</tr>
|
|
47
|
+
</table>
|
|
48
|
+
</td>
|
|
49
|
+
</tr>
|
|
50
|
+
{% endfor %}
|
|
51
|
+
</table>
|
|
52
|
+
{% endif %}
|
|
53
|
+
|
|
54
|
+
<!-- View all link -->
|
|
55
|
+
<table role="presentation" cellspacing="0" cellpadding="0" border="0" align="center" width="100%">
|
|
56
|
+
<tr>
|
|
57
|
+
<td style="text-align: center; padding: 4px 0 16px 0;">
|
|
58
|
+
<a href="{{ product_url }}" style="display: inline-block; background-color: #6366f1; color: #ffffff; font-size: 16px; font-weight: 600; text-decoration: none; padding: 14px 36px; border-radius: 8px; mso-padding-alt: 0; text-align: center;">
|
|
59
|
+
<!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%; mso-text-raise: 26pt;"> </i><![endif]-->
|
|
60
|
+
<span style="mso-text-raise: 13pt;">View Dashboard</span>
|
|
61
|
+
<!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%;"> </i><![endif]-->
|
|
62
|
+
</a>
|
|
63
|
+
</td>
|
|
64
|
+
</tr>
|
|
65
|
+
</table>
|
|
66
|
+
|
|
67
|
+
<p style="margin: 16px 0 0 0; font-size: 14px; line-height: 1.5; color: #8c8c9a; text-align: center;">
|
|
68
|
+
You're receiving this because you're subscribed to {{ period }} digests.
|
|
69
|
+
</p>
|
|
70
|
+
{% endblock %}
|