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.
@@ -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;">&nbsp;</i><![endif]-->
72
+ <span style="mso-text-raise: 13pt;">{{ action_text }}</span>
73
+ <!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%;">&nbsp;</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
+ &copy; {{ 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
+ &nbsp;&middot;&nbsp;
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;">&nbsp;</i><![endif]-->
60
+ <span style="mso-text-raise: 13pt;">View Dashboard</span>
61
+ <!--[if mso]><i style="letter-spacing: 36px; mso-font-width: -100%;">&nbsp;</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 %}