@splendidlabz/emails 1.0.0

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.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ # @splendidlabz/emails
2
+
3
+ Render Astro components into sendable email, and send them.
4
+
5
+ ## Install
6
+
7
+ npm install @splendidlabz/emails
8
+
9
+ Requires `astro >= 5.16.0` as a peer.
10
+
11
+ Provider SDKs are optional peers — install the one you send with, and nothing else:
12
+
13
+ npm install postmark
14
+
15
+ ## Surfaces
16
+
17
+ import { createEmail } from '@splendidlabz/emails'
18
+ import { EmailBase } from '@splendidlabz/emails/components'
19
+ import { Postmark } from '@splendidlabz/emails/providers'
20
+
21
+ ## Render
22
+
23
+ const { html, text } = await createEmail(PasswordReset, {
24
+ subject: 'Reset your password',
25
+ resetUrl,
26
+ })
27
+
28
+ `createEmail` renders through Astro's container API, so it runs anywhere on the
29
+ server — API routes, actions, background jobs — with no request in play. CSS is
30
+ inlined with `juice`; the text version is derived from the inlined HTML.
31
+
32
+ Write template styles with `<style is:inline>`. Astro scopes and hoists plain
33
+ `<style>` blocks into bundled CSS, which doesn't exist in a container render —
34
+ they disappear from the output silently.
35
+
36
+ ## Send
37
+
38
+ const postmark = Postmark(process.env.POSTMARK_KEY, {
39
+ from: 'Zell <zell@splendidlabz.com>',
40
+ })
41
+
42
+ await postmark.sendEmail({ to, subject, html, text })
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@splendidlabz/emails",
3
+ "prettier": "@splendidlabz/prettier-config",
4
+ "version": "1.0.0",
5
+ "homepage": "https://splendidlabz.com/docs/emails",
6
+ "description": "Render Astro components into sendable email, and send them",
7
+ "type": "module",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": "./src/index.js",
11
+ "./components": "./src/components/index.js",
12
+ "./providers": "./src/providers/index.js"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "!**/__tests__",
17
+ "!**/*.spec.js",
18
+ "!**/*.test.js"
19
+ ],
20
+ "scripts": {
21
+ "lint": "eslint . --fix",
22
+ "prepack": "vitest run",
23
+ "test": "vitest run",
24
+ "test:watch": "vitest"
25
+ },
26
+ "author": "Zell Liew <zellwk@gmail.com>",
27
+ "dependencies": {
28
+ "html-to-text": "^9.0.5",
29
+ "juice": "^11.1.1"
30
+ },
31
+ "peerDependencies": {
32
+ "astro": ">=5.16.0",
33
+ "postmark": "^4.0.7"
34
+ },
35
+ "peerDependenciesMeta": {
36
+ "postmark": {
37
+ "optional": true
38
+ }
39
+ },
40
+ "devDependencies": {
41
+ "@splendidlabz/eslint-config": "2.1.6",
42
+ "@splendidlabz/prettier-config": "1.2.0",
43
+ "postmark": "^4.0.7",
44
+ "vitest": "^4.1.4"
45
+ }
46
+ }
@@ -0,0 +1,24 @@
1
+ ---
2
+ // Reference: https://www.goodemailcode.com/email-code/template
3
+ /**
4
+ * Email <body> wrapping content in an ARIA article region.
5
+ * @typedef {Object} Props
6
+ * @property {string} [lang='en'] - lang attribute.
7
+ * @property {string} [dir='ltr'] - Text direction.
8
+ * @property {string} [subject] - aria-label for the article region.
9
+ */
10
+ const { lang = 'en', dir = 'ltr', subject } = Astro.props
11
+ ---
12
+
13
+ <body xml:lang={lang}>
14
+ <div
15
+ role="article"
16
+ aria-roledescription="email"
17
+ aria-label={subject}
18
+ {lang}
19
+ {dir}
20
+ style="font-size: medium; font-size: max(16px, 1rem)"
21
+ >
22
+ <slot />
23
+ </div>
24
+ </body>
@@ -0,0 +1,15 @@
1
+ ---
2
+ // Reference: https://www.goodemailcode.com/email-code/container
3
+ // Need to figure out background color stuff
4
+ /**
5
+ * Centered, width-constrained email container (with MSO table fallback). Slot-only; takes no props.
6
+ * @typedef {Object} Props
7
+ */
8
+ ---
9
+
10
+ <!--[if true]> <table role="presentation" style="width:37.5em" align="center"><tr><td> <![endif]-->
11
+ <div class="container" style="max-width:37.5em; margin:0 auto">
12
+ <slot />
13
+ </div>
14
+
15
+ <!--[if true]> </td></tr></table> <![endif]-->
@@ -0,0 +1,36 @@
1
+ ---
2
+ import Body from './Body.astro'
3
+ import Container from './Container.astro'
4
+ import HTML from './HTML.astro'
5
+ import Head from './Head.astro'
6
+ import Preview from './Preview.astro'
7
+ import emailBaseCSS from './email-base.css?raw'
8
+
9
+ /**
10
+ * Full email scaffold (HTML > Head/Body > Preview + Container). Named slots: `head` for extra
11
+ * head tags, `styles` for extra <style>; default slot is the email body.
12
+ * @typedef {Object} Props
13
+ * @property {string} [lang='en'] - lang attribute.
14
+ * @property {string} [dir='ltr'] - Text direction.
15
+ * @property {string} [subject] - Email subject (title + article label).
16
+ * @property {string} [previewText=''] - Inbox preview/preheader text.
17
+ */
18
+ const { lang = 'en', dir = 'ltr', subject, previewText = '' } = Astro.props
19
+ ---
20
+
21
+ <HTML {lang} {dir}>
22
+ <Head {subject}>
23
+ <slot name="head" />
24
+
25
+ <style is:inline set:html={emailBaseCSS}></style>
26
+
27
+ <slot name="styles" />
28
+ </Head>
29
+
30
+ <Body {lang}>
31
+ <Preview content={previewText} />
32
+ <Container>
33
+ <slot />
34
+ </Container>
35
+ </Body>
36
+ </HTML>
@@ -0,0 +1,20 @@
1
+ ---
2
+ // Reference: https://www.goodemailcode.com/email-code/template
3
+ /**
4
+ * Root <html> element for an email, with VML/Office namespaces.
5
+ * @typedef {Object} Props
6
+ * @property {string} [lang='en'] - lang attribute.
7
+ * @property {string} [dir='ltr'] - Text direction.
8
+ */
9
+ const { lang = 'en', dir = 'ltr' } = Astro.props
10
+ ---
11
+
12
+ <html
13
+ {lang}
14
+ {dir}
15
+ xmlns:v="urn:schemas-microsoft-com:vml"
16
+ xmlns:o="urn:schemas-microsoft-com:office:office"
17
+ >
18
+ <slot />
19
+
20
+ </html>
@@ -0,0 +1,39 @@
1
+ ---
2
+ // Reference: https://www.goodemailcode.com/email-code/template
3
+ /**
4
+ * Email <head> with boilerplate meta tags and a title. Extra <head> tags go in the default slot.
5
+ * @typedef {Object} Props
6
+ * @property {string} subject - Email subject, rendered as the <title>.
7
+ */
8
+ const { subject } = Astro.props
9
+ ---
10
+
11
+ <head>
12
+ <meta charset="utf-8" />
13
+ <meta
14
+ name="viewport"
15
+ content="width=device-width,initial-scale=1 user-scalable=yes"
16
+ />
17
+ <meta
18
+ name="format-detection"
19
+ content="telephone=no, date=no, address=no, email=no, url=no"
20
+ />
21
+ <meta name="x-apple-disable-message-reformatting" />
22
+ <title>{subject}</title>
23
+
24
+ <!-- Allow dark mode -->
25
+ <meta name="color-scheme" content="light dark" />
26
+ <meta name="supported-color-schemes" content="light dark only" />
27
+
28
+ <!--[if mso]>
29
+ <noscript>
30
+ <xml>
31
+ <o:OfficeDocumentSettings>
32
+ <o:PixelsPerInch>96</o:PixelsPerInch>
33
+ </o:OfficeDocumentSettings>
34
+ </xml>
35
+ </noscript>
36
+ <![endif]-->
37
+
38
+ <slot />
39
+ </head>
@@ -0,0 +1,19 @@
1
+ ---
2
+ // Reference: https://www.goodemailcode.com/email-code/preheader
3
+ /**
4
+ * Hidden preheader text shown in inbox previews, padded to hide trailing body text.
5
+ * @typedef {Object} Props
6
+ * @property {string} [content=''] - Preview text. Nothing renders when empty.
7
+ */
8
+ const { content = '' } = Astro.props
9
+ const padding = Array.from({ length: 160 }, () => '&#8199;&#847;').join(' ')
10
+ ---
11
+
12
+ <!-- Empty spaces to prevent main body from showing in preview text -->{
13
+ content && (
14
+ <>
15
+ <div style="display:none;">{content}</div>
16
+ <div style="display:none;" aria-hidden="true" set:html={padding}></div>
17
+ </>
18
+ )
19
+ }
@@ -0,0 +1,169 @@
1
+ :root {
2
+ color-scheme: light dark;
3
+ supported-color-schemes: light dark;
4
+ }
5
+
6
+ /* Base typography */
7
+ body {
8
+ line-height: 1.5;
9
+ font-family:
10
+ SF Pro,
11
+ system-ui,
12
+ Helvetica,
13
+ Arial,
14
+ sans-serif;
15
+ }
16
+
17
+ h1,
18
+ h2,
19
+ h3,
20
+ h4,
21
+ h5,
22
+ h6,
23
+ p,
24
+ ul,
25
+ ol,
26
+ pre,
27
+ figure {
28
+ margin-top: 1rem;
29
+ margin-bottom: 1rem;
30
+ }
31
+
32
+ h1 {
33
+ font-size: 2em; /* 32px */
34
+ }
35
+
36
+ h2 {
37
+ margin-top: 2rem;
38
+ font-size: 1.5em; /* 24px */
39
+ }
40
+
41
+ h3 {
42
+ margin-top: 1.5rem;
43
+ font-size: 1.25em; /* 20px */
44
+ }
45
+
46
+ h4 {
47
+ font-size: 1rem; /* 16px */
48
+ }
49
+
50
+ h5 {
51
+ font-size: 0.875rem; /* 14px */
52
+ }
53
+
54
+ h6 {
55
+ font-size: 0.75rem; /* 12px */
56
+ }
57
+
58
+ ul,
59
+ ol {
60
+ list-style-position: outside;
61
+ margin-left: 0.5rem;
62
+ }
63
+
64
+ li + li {
65
+ margin-top: 0.25rem;
66
+ }
67
+
68
+ strong {
69
+ font-weight: bold;
70
+ }
71
+
72
+ em {
73
+ font-style: italic;
74
+ }
75
+
76
+ .strikethrough {
77
+ text-decoration: line-through;
78
+ }
79
+
80
+ code {
81
+ font-size: 0.83em;
82
+ line-height: 1.4;
83
+ font-family: Menlo, Monaco, monospace;
84
+ background: #fafafa;
85
+ border: 1px solid #ccc;
86
+ padding: 0 0.5em;
87
+ color: #2d2d2d;
88
+ }
89
+
90
+ pre {
91
+ font-size: 0.83em;
92
+ line-height: 1.3;
93
+ display: block;
94
+ margin-inline: auto;
95
+ padding: 0.5em 0.75em;
96
+ background: #333;
97
+ color: #eee;
98
+ text-align: left;
99
+ hyphens: none;
100
+ tab-size: 2;
101
+ max-width: 100%;
102
+ overflow: auto;
103
+ }
104
+
105
+ /* Basic elements */
106
+
107
+ a {
108
+ text-decoration: underline;
109
+ }
110
+
111
+ img {
112
+ max-width: 100% !important;
113
+ margin-left: 0 !important;
114
+ margin-right: 0 !important;
115
+ }
116
+
117
+ /* https://stackoverflow.com/a/32891509 */
118
+ .preheader {
119
+ display: none !important;
120
+ visibility: hidden;
121
+ opacity: 0;
122
+ color: transparent;
123
+ height: 0;
124
+ width: 0;
125
+ }
126
+
127
+ .button {
128
+ display: inline-block;
129
+ border: 2px solid black;
130
+ background: #eee;
131
+ color: black;
132
+ padding: 0.5em 1em;
133
+ text-decoration: none;
134
+ }
135
+
136
+ /* Shapes */
137
+ .round-rect {
138
+ border-radius: 0.25em;
139
+ }
140
+
141
+ .pill {
142
+ border-radius: 5em;
143
+ }
144
+
145
+ /* Utilities */
146
+ .center {
147
+ margin-left: auto;
148
+ margin-right: auto;
149
+ }
150
+
151
+ .text-center {
152
+ text-align: center;
153
+ }
154
+
155
+ .preheader {
156
+ display: none;
157
+ visibility: hidden;
158
+ }
159
+
160
+ .main-content {
161
+ max-width: 37.5em;
162
+ margin-left: auto;
163
+ margin-right: auto;
164
+ }
165
+
166
+ .footer {
167
+ border-top: 1px dotted #999;
168
+ padding: 1.5em 0;
169
+ }
@@ -0,0 +1,8 @@
1
+ import Body from './Body.astro'
2
+ import Container from './Container.astro'
3
+ import EmailBase from './EmailBase.astro'
4
+ import HTML from './HTML.astro'
5
+ import Head from './Head.astro'
6
+ import Preview from './Preview.astro'
7
+
8
+ export { Body, Container, EmailBase, HTML, Head, Preview }
@@ -0,0 +1,16 @@
1
+ import { experimental_AstroContainer } from 'astro/container'
2
+ import { convert } from 'html-to-text'
3
+ import juice from 'juice'
4
+
5
+ // Creates HTML and Text content for email
6
+ export async function createEmail(template, data) {
7
+ const container = await experimental_AstroContainer.create()
8
+ const html = await container.renderToString(template, { props: data })
9
+ const juiced = juice(html) // inline css
10
+ const text = convert(juiced)
11
+
12
+ return {
13
+ html: juiced,
14
+ text,
15
+ }
16
+ }
package/src/index.js ADDED
@@ -0,0 +1 @@
1
+ export * from './create-email.js'
@@ -0,0 +1 @@
1
+ export * from './postmark.js'
@@ -0,0 +1,32 @@
1
+ import postmark from 'postmark'
2
+
3
+ const DEFAULT_OPTIONS = {
4
+ MessageStream: 'outbound', // outbound | broadcast
5
+ }
6
+
7
+ export function Postmark(API_KEY, options) {
8
+ const POSTMARK_KEY = API_KEY || process.env.POSTMARK_KEY
9
+ const client = new postmark.ServerClient(POSTMARK_KEY)
10
+ const baseOpts = Object.assign({}, DEFAULT_OPTIONS, options)
11
+
12
+ return {
13
+ constructEmailAddress(name, email) {
14
+ return `${name} <${email}>`
15
+ },
16
+
17
+ sendEmail(options) {
18
+ const opts = Object.assign({}, baseOpts, options)
19
+ const { from, to, replyTo, subject, html, text, ...rest } = opts
20
+
21
+ return client.sendEmail({
22
+ From: from,
23
+ To: to,
24
+ ReplyTo: replyTo,
25
+ Subject: subject,
26
+ HTMLBody: html,
27
+ TextBody: text,
28
+ ...rest,
29
+ })
30
+ },
31
+ }
32
+ }