@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 +42 -0
- package/package.json +46 -0
- package/src/components/Body.astro +24 -0
- package/src/components/Container.astro +15 -0
- package/src/components/EmailBase.astro +36 -0
- package/src/components/HTML.astro +20 -0
- package/src/components/Head.astro +39 -0
- package/src/components/Preview.astro +19 -0
- package/src/components/email-base.css +169 -0
- package/src/components/index.js +8 -0
- package/src/create-email.js +16 -0
- package/src/index.js +1 -0
- package/src/providers/index.js +1 -0
- package/src/providers/postmark.js +32 -0
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 }, () => ' ͏').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
|
+
}
|