@live-change/email-service 0.9.33 → 0.9.35

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/browser.js ADDED
@@ -0,0 +1,55 @@
1
+ import App from '@live-change/framework'
2
+ const app = App.app()
3
+
4
+ import definition from './definition.js'
5
+ import config from './config.js'
6
+
7
+ import { chromium } from 'playwright'
8
+
9
+ import PQueue from 'p-queue'
10
+ import got from "got"
11
+
12
+ import dns from "dns/promises"
13
+
14
+ const browserQueue = new PQueue({ concurrency: config.browser.concurrency })
15
+
16
+ async function newBrowser() {
17
+ if(config.browser.webSocketDebuggerUrl) {
18
+ const browser = await chromium.connect({ wsEndpoint: config.browser.webSocketDebuggerUrl })
19
+ return browser
20
+ } else if(config.browser.url) {
21
+ const browserInfo = await got.post(config.browser.url + '/json/version').json()
22
+ const browser = await chromium.connect({ wsEndpoint: browserInfo.webSocketDebuggerUrl })
23
+ return browser
24
+ } if(config.browser.host) {
25
+ const ip = await dns.resolve4(config.browser.host)
26
+ const browserInfoUrl = `http://${ip}:${config.browser.port}/json/version`
27
+ console.log("Browser info url", browserInfoUrl)
28
+ const browserInfo = await got.post(browserInfoUrl).json()
29
+ console.log("Browser info", browserInfo)
30
+ try {
31
+ const browser = await chromium.connectOverCDP(browserInfo.webSocketDebuggerUrl)
32
+ return browser
33
+ } catch (error) {
34
+ console.error("Failed to connect to browser", error)
35
+ throw error
36
+ }
37
+ } else {
38
+ const browser = await chromium.launch({
39
+ headless: true
40
+ })
41
+ return browser
42
+ }
43
+ }
44
+
45
+ export async function runWithBrowser(func) {
46
+ return await browserQueue.add(async () => {
47
+ const browser = await newBrowser()
48
+ try {
49
+ const result = await func(browser)
50
+ return result
51
+ } finally {
52
+ await browser.close()
53
+ }
54
+ })
55
+ }
package/config.js ADDED
@@ -0,0 +1,53 @@
1
+ import definition from './definition.js'
2
+ import crypto from 'crypto'
3
+
4
+ // Helper function to get value with fallback
5
+ const getValue = (configValue, envValue, defaultValue) =>
6
+ configValue ?? envValue ?? defaultValue
7
+
8
+ // Helper function to convert value to number
9
+ const getNumberValue = (configValue, envValue, defaultValue) =>
10
+ +(getValue(configValue, envValue, defaultValue))
11
+
12
+ // Browser configuration
13
+ const browser = {
14
+ url: getValue(definition.config.browser?.url, process.env.BROWSER_URL),
15
+ webSocketDebuggerUrl: getValue(
16
+ definition.config.browser?.webSocketDebuggerUrl,
17
+ process.env.BROWSER_WEBSOCKET_DEBUGGER_URL
18
+ ),
19
+ host: getValue(definition.config.browser?.host, process.env.BROWSER_HOST),
20
+ port: getNumberValue(definition.config.browser?.port, process.env.BROWSER_PORT, 9222),
21
+ concurrency: getNumberValue(definition.config.browser?.concurrency, process.env.BROWSER_CONCURRENCY, 1),
22
+ renderAuthenticationKey: getValue(
23
+ definition.config.browser?.renderAuthenticationKey,
24
+ null,
25
+ crypto.randomBytes(24).toString('hex')
26
+ ),
27
+ ssrUrl: getValue(
28
+ definition.config.browser?.ssrUrl,
29
+ process.env.SSR_URL,
30
+ 'http://localhost:8001'
31
+ ),
32
+ }
33
+
34
+ // SMTP configuration
35
+ const smtp = {
36
+ host: getValue(definition.config.smtp?.host, process.env.SMTP_HOST),
37
+ port: getNumberValue(definition.config.smtp?.port, process.env.SMTP_PORT, 587),
38
+ auth: {
39
+ user: getValue(definition.config.smtp?.user, process.env.SMTP_USER),
40
+ pass: getValue(definition.config.smtp?.password, process.env.SMTP_PASSWORD),
41
+ },
42
+ // deduce secure from port, include all secure ports
43
+ secure: getValue(definition.config.smtp?.secure, [465, 587, 2525].includes(definition.config.smtp?.port)),
44
+ ignoreTLS: getValue(definition.config.smtp?.ignoreTLS, undefined),
45
+ }
46
+
47
+ const renderMethod = getValue(definition.config.renderMethod, process.env.EMAIL_RENDER_METHOD, 'juice')
48
+
49
+ definition.clientConfig = {}
50
+
51
+ const config = { browser, smtp, renderMethod }
52
+
53
+ export default config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@live-change/email-service",
3
- "version": "0.9.33",
3
+ "version": "0.9.35",
4
4
  "description": "",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -21,14 +21,17 @@
21
21
  "url": "https://www.viamage.com/"
22
22
  },
23
23
  "dependencies": {
24
- "@live-change/framework": "^0.9.33",
24
+ "@live-change/framework": "^0.9.35",
25
25
  "got": "^11.8.6",
26
26
  "html-to-text": "8.1.0",
27
27
  "inline-css": "4.0.2",
28
28
  "jsdom": "^24.1.0",
29
29
  "juice": "11.0.0",
30
- "nodemailer": "^6.7.2"
30
+ "nodemailer": "^6.7.2",
31
+ "postcss": "8.5.3",
32
+ "postcss-calc": "10.1.1",
33
+ "postcss-custom-properties": "14.0.4"
31
34
  },
32
- "gitHead": "c6eaa7764dc12b9489b74386b1227b71d0640e09",
35
+ "gitHead": "abe67aa58ccf8246b8b9fde832287b5305480b91",
33
36
  "type": "module"
34
37
  }
@@ -0,0 +1,134 @@
1
+ const url = process.argv[2]
2
+
3
+ import got from 'got'
4
+ import juice from 'juice'
5
+
6
+ import fs from 'fs'
7
+
8
+ import { JSDOM } from 'jsdom'
9
+
10
+ import postcss from 'postcss';
11
+ import postcssCustomProperties from 'postcss-custom-properties';
12
+ import postcssCalc from 'postcss-calc';
13
+
14
+ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms))
15
+
16
+ console.log('url', url)
17
+ const response = await got(url)
18
+ let body = response.body
19
+
20
+ fs.writeFileSync('body.html', body)
21
+
22
+ const juiceOptions = {
23
+ webResources: {
24
+ scripts: false,
25
+ links: true,
26
+ images: false,
27
+ resolveCSSVariables: true,
28
+ relativeTo: url
29
+ }
30
+ }
31
+
32
+ const processCSS = async (css) => {
33
+ const result = await postcss([
34
+ postcssCustomProperties(),
35
+ postcssCalc()
36
+ ]).process(css, { from: undefined });
37
+
38
+ return result.css;
39
+ };
40
+
41
+ const fetchAndProcessCSS = async (html, baseUrl) => {
42
+ const dom = new JSDOM(html);
43
+ const document = dom.window.document;
44
+ let collectedCSS = "";
45
+
46
+ // Pobieramy CSS z <style> w dokumencie
47
+ const styleTags = document.querySelectorAll('style');
48
+ for (const styleTag of styleTags) {
49
+ collectedCSS += styleTag.textContent + "\n";
50
+ styleTag.remove(); // Usuwamy, bo później wstawimy jeden poprawiony <style>
51
+ }
52
+
53
+ // Pobieramy CSS z <link rel="stylesheet" href="...">
54
+ const linkTags = document.querySelectorAll('link[rel="stylesheet"]');
55
+ for (const linkTag of linkTags) {
56
+ const href = linkTag.href;
57
+ if (href) {
58
+ try {
59
+ const absoluteUrl = new URL(href, baseUrl).href;
60
+ console.log(`Fetching CSS from: ${absoluteUrl}`);
61
+ const response = await got(absoluteUrl);
62
+ collectedCSS += response.body + "\n";
63
+ } catch (error) {
64
+ console.warn(`Failed to fetch CSS: ${href}`, error.message);
65
+ }
66
+ }
67
+ linkTag.remove(); // Usuwamy, bo CSS już inline'ujemy
68
+ }
69
+
70
+ fs.writeFileSync('collectedCSS.css', collectedCSS)
71
+ // Przetwarzamy zebrany CSS
72
+ const processedCSS = await processCSS(collectedCSS);
73
+
74
+ fs.writeFileSync('processedCSS.css', processedCSS)
75
+ // Dodajemy nowy przetworzony CSS do dokumentu
76
+ const newStyleTag = document.createElement('style');
77
+ newStyleTag.textContent = processedCSS;
78
+ document.head.appendChild(newStyleTag);
79
+
80
+ return dom.serialize();
81
+ };
82
+
83
+ body = await fetchAndProcessCSS(body, url)
84
+
85
+ fs.writeFileSync('body-css.html', body)
86
+
87
+ /*body = await juice(body, juiceOptions)*/
88
+ body = await new Promise((resolve, reject) => {
89
+ juice.juiceResources(body, juiceOptions, (err, html) => {
90
+ if(err) reject(err)
91
+ else resolve(html)
92
+ })
93
+ })
94
+
95
+ const dom = new JSDOM(body)
96
+ fs.writeFileSync('body-juced.html', body)
97
+
98
+ const htmlElement = dom.window.document.querySelector('[data-html]')
99
+
100
+ // traverse all elements and clear all data attributes
101
+ const cleanDataAttributes = (element) => {
102
+ // Remove all data attributes
103
+ for(const attr of element.attributes) {
104
+ if(attr.name.startsWith('data-')) {
105
+ element.removeAttribute(attr.name)
106
+ }
107
+ }
108
+ for(const child of element.children) {
109
+ cleanDataAttributes(child)
110
+ }
111
+ }
112
+ cleanDataAttributes(htmlElement)
113
+
114
+ fs.writeFileSync('body-cleaned.html', htmlElement.outerHTML)
115
+ //console.log(htmlElement.outerHTML)
116
+
117
+ // const juiceOptions = {
118
+ // webResources: {
119
+ // scripts: false,
120
+ // links: true,
121
+ // images: false,
122
+ // resolveCSSVariables: true,
123
+ // relativeTo: url
124
+ // }
125
+ // }
126
+
127
+ // const html = await new Promise((resolve, reject) => {
128
+ // juice.juiceResources(response.body, juiceOptions, (err, html) => {
129
+ // if(err) reject(err)
130
+ // else resolve(html)
131
+ // })
132
+ // })
133
+
134
+ // console.log(html)
@@ -0,0 +1,95 @@
1
+ import { chromium } from 'playwright';
2
+ import fs from 'fs/promises';
3
+
4
+ const url = process.argv[2]
5
+
6
+ const browser = await chromium.launch();
7
+ const page = await browser.newPage();
8
+
9
+ // Załaduj stronę
10
+ await page.goto(url); // Podmień na swoją stronę
11
+
12
+ const inlineHtml = await page.evaluate(() => {
13
+ function getFilteredComputedStyles(element) {
14
+ const computed = window.getComputedStyle(element);
15
+ const defaultElement = document.createElement(element.tagName);
16
+ document.body.appendChild(defaultElement);
17
+ const defaultStyles = window.getComputedStyle(defaultElement);
18
+
19
+ const filteredStyles = {};
20
+
21
+ for (let i = 0; i < computed.length; i++) {
22
+ const prop = computed[i];
23
+ const computedValue = computed.getPropertyValue(prop);
24
+ const defaultValue = defaultStyles.getPropertyValue(prop);
25
+
26
+ if (computedValue !== defaultValue) {
27
+ filteredStyles[prop] = computedValue;
28
+ }
29
+ }
30
+
31
+ document.body.removeChild(defaultElement);
32
+ return filteredStyles;
33
+ }
34
+
35
+ function cssStyleDeclarationToString(styles) {
36
+ return Object.entries(styles)
37
+ .map(([prop, value]) => `${prop}: ${value};`)
38
+ .join(' ');
39
+ }
40
+
41
+ function inlineImportantStyles(root) {
42
+ const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
43
+
44
+ while (treeWalker.nextNode()) {
45
+ const element = treeWalker.currentNode;
46
+ const filteredStyles = getFilteredComputedStyles(element);
47
+ const inheritedStyles = getInheritedStyles(element);
48
+
49
+ // Usuwamy style, które są takie same jak u rodzica
50
+ for (const prop in inheritedStyles) {
51
+ if (filteredStyles[prop] === inheritedStyles[prop]) {
52
+ delete filteredStyles[prop];
53
+ }
54
+ }
55
+
56
+ const inlineStyle = cssStyleDeclarationToString(filteredStyles);
57
+ if (inlineStyle) {
58
+ element.setAttribute('style', inlineStyle);
59
+ }
60
+
61
+ // Usuwanie klas
62
+ element.removeAttribute('class');
63
+ }
64
+ }
65
+
66
+ function getInheritedStyles(element) {
67
+ const inheritedStyles = {};
68
+ let parent = element.parentElement;
69
+
70
+ while (parent) {
71
+ const parentStyles = window.getComputedStyle(parent);
72
+ for (let i = 0; i < parentStyles.length; i++) {
73
+ const prop = parentStyles[i];
74
+ if (!inheritedStyles[prop]) {
75
+ inheritedStyles[prop] = parentStyles.getPropertyValue(prop);
76
+ }
77
+ }
78
+ parent = parent.parentElement;
79
+ }
80
+
81
+ return inheritedStyles;
82
+ }
83
+
84
+
85
+ const clonedDoc = document.documentElement.cloneNode(true);
86
+ inlineImportantStyles(clonedDoc);
87
+ return `<!DOCTYPE html>\n${clonedDoc.outerHTML}`;
88
+ });
89
+
90
+ // Zapisz do pliku
91
+ await fs.writeFile('output.html', inlineHtml);
92
+
93
+ console.log('Zapisano HTML z inline CSS jako output.html');
94
+
95
+ await browser.close();
@@ -0,0 +1,213 @@
1
+ import { chromium } from 'playwright'
2
+ import fs from 'fs/promises'
3
+
4
+ const url = process.argv[2]
5
+
6
+ const browser = await chromium.launch()
7
+ const page = await browser.newPage()
8
+
9
+ // Załaduj stronę
10
+ await page.goto(url) // Podmień na swoją stronę
11
+
12
+ const inlineHtml = await page.evaluate(() => {
13
+ const ALLOWED_CSS_PROPERTIES = new Set([
14
+ 'background',
15
+ 'background-blend-mode',
16
+ 'background-clip',
17
+ 'background-color',
18
+ 'background-image',
19
+ 'background-origin',
20
+ 'background-position',
21
+ 'background-repeat',
22
+ 'background-size',
23
+ 'border',
24
+ 'border-bottom',
25
+ 'border-bottom-color',
26
+ 'border-bottom-left-radius',
27
+ 'border-bottom-right-radius',
28
+ 'border-bottom-style',
29
+ 'border-bottom-width',
30
+ 'border-collapse',
31
+ 'border-color',
32
+ 'border-left',
33
+ 'border-left-color',
34
+ 'border-left-style',
35
+ 'border-left-width',
36
+ 'border-radius',
37
+ 'border-right',
38
+ 'border-right-color',
39
+ 'border-right-style',
40
+ 'border-right-width',
41
+ 'border-spacing',
42
+ 'border-style',
43
+ 'border-top',
44
+ 'border-top-color',
45
+ 'border-top-left-radius',
46
+ 'border-top-right-radius',
47
+ 'border-top-style',
48
+ 'border-top-width',
49
+ 'border-width',
50
+ 'box-sizing',
51
+ 'color',
52
+ 'display',
53
+ 'float',
54
+ 'font',
55
+ 'font-family',
56
+ 'font-feature-settings',
57
+ 'font-size',
58
+ 'font-size-adjust',
59
+ 'font-stretch',
60
+ 'font-style',
61
+ 'font-weight',
62
+ 'height',
63
+ 'letter-spacing',
64
+ 'line-height',
65
+ 'list-style',
66
+ 'list-style-position',
67
+ 'list-style-type',
68
+ 'margin',
69
+ 'margin-bottom',
70
+ 'margin-left',
71
+ 'margin-right',
72
+ 'margin-top',
73
+ 'max-height',
74
+ 'max-width',
75
+ 'min-height',
76
+ 'min-width',
77
+ 'opacity',
78
+ 'outline',
79
+ 'outline-color',
80
+ 'outline-style',
81
+ 'outline-width',
82
+ 'overflow',
83
+ 'overflow-x',
84
+ 'overflow-y',
85
+ 'padding',
86
+ 'padding-bottom',
87
+ 'padding-left',
88
+ 'padding-right',
89
+ 'padding-top',
90
+ 'table-layout',
91
+ 'text-align',
92
+ 'text-decoration',
93
+ 'text-decoration-color',
94
+ 'text-decoration-line',
95
+ 'text-decoration-style',
96
+ 'text-indent',
97
+ 'text-overflow',
98
+ 'text-transform',
99
+ 'vertical-align',
100
+ 'white-space',
101
+ 'width',
102
+ 'word-break',
103
+ 'word-spacing',
104
+ 'word-wrap'
105
+ ])
106
+
107
+ function getCSSDefinedStylesWithLayers(element) {
108
+ const computedStyles = window.getComputedStyle(element)
109
+ const styles = {}
110
+ const layerRules = []
111
+
112
+ for (const sheet of document.styleSheets) {
113
+ try {
114
+ for (const rule of sheet.cssRules) {
115
+ if (rule instanceof CSSLayerBlockRule) {
116
+ // Przechodzi przez zdefiniowane warstwy
117
+ for (const subRule of rule.cssRules) {
118
+ if (subRule instanceof CSSStyleRule && element.matches(subRule.selectorText)) {
119
+ layerRules.push(subRule)
120
+ }
121
+ }
122
+ } else if (rule instanceof CSSStyleRule && element.matches(rule.selectorText)) {
123
+ layerRules.push(rule)
124
+ }
125
+ }
126
+ } catch (e) {
127
+ console.warn('Nie można odczytać stylów z:', sheet.href)
128
+ }
129
+ }
130
+
131
+ // Zastosowanie reguł w kolejności ich priorytetu
132
+ for (const rule of layerRules) {
133
+ for (const prop of rule.style) {
134
+ styles[prop] = computedStyles.getPropertyValue(prop)
135
+ }
136
+ }
137
+
138
+ return styles
139
+ }
140
+
141
+ function getFilteredComputedStyles(element) {
142
+ const definedStyles = getCSSDefinedStylesWithLayers(element)
143
+ const filteredStyles = {}
144
+
145
+ for (const prop in definedStyles) {
146
+ if (ALLOWED_CSS_PROPERTIES.has(prop)) {
147
+ filteredStyles[prop] = definedStyles[prop]
148
+ }
149
+ }
150
+
151
+ return filteredStyles
152
+ }
153
+
154
+ function cssStyleDeclarationToString(styles) {
155
+ return Object.entries(styles)
156
+ .map(([prop, value]) => `${prop}: ${value};`)
157
+ .join(' ')
158
+ }
159
+
160
+ function inlineImportantStyles(root) {
161
+ const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
162
+
163
+ while (treeWalker.nextNode()) {
164
+ const element = treeWalker.currentNode
165
+ const filteredStyles = getFilteredComputedStyles(element)
166
+ const inheritedStyles = getInheritedStyles(element)
167
+
168
+ // Usuwamy style, które są takie same jak u rodzica
169
+ for (const prop in inheritedStyles) {
170
+ if (filteredStyles[prop] === inheritedStyles[prop]) {
171
+ delete filteredStyles[prop]
172
+ }
173
+ }
174
+
175
+ const inlineStyle = cssStyleDeclarationToString(filteredStyles)
176
+ if (inlineStyle) {
177
+ element.setAttribute('style', inlineStyle)
178
+ }
179
+
180
+ // Usuwanie klas
181
+ element.removeAttribute('class')
182
+ }
183
+ }
184
+
185
+ function getInheritedStyles(element) {
186
+ const inheritedStyles = {}
187
+ let parent = element.parentElement
188
+
189
+ while (parent) {
190
+ const parentStyles = window.getComputedStyle(parent)
191
+ for (let i = 0; i < parentStyles.length; i++) {
192
+ const prop = parentStyles[i]
193
+ if (!inheritedStyles[prop]) {
194
+ inheritedStyles[prop] = parentStyles.getPropertyValue(prop)
195
+ }
196
+ }
197
+ parent = parent.parentElement
198
+ }
199
+
200
+ return inheritedStyles
201
+ }
202
+ const htmlElement = document.querySelector('[data-html]')
203
+ // Uruchomienie dla całego dokumentu
204
+ inlineImportantStyles(htmlElement)
205
+ return `<!DOCTYPE html>\n${htmlElement.outerHTML}`
206
+ })
207
+
208
+ // Zapisz do pliku
209
+ await fs.writeFile('output.html', inlineHtml)
210
+
211
+ console.log('Zapisano HTML z inline CSS jako output.html')
212
+
213
+ await browser.close()
package/render.js CHANGED
@@ -11,6 +11,8 @@ import { ObservableValue } from '@live-change/dao'
11
11
  import definition from './definition.js'
12
12
  const config = definition.config
13
13
 
14
+ import { runWithBrowser } from './browser.js'
15
+
14
16
  const publicDir = config.publicDir || 'front/public/'
15
17
 
16
18
  const authenticationKey = new ObservableValue(
@@ -40,6 +42,7 @@ definition.authenticator({
40
42
  }
41
43
  }
42
44
  })
45
+
43
46
  function processElement(element, images) {
44
47
  for(let i = 0; i < element.attributes.length; i++) {
45
48
  const attribute = element.attributes[i]
@@ -73,14 +76,7 @@ function processElement(element, images) {
73
76
  }
74
77
  }
75
78
 
76
- async function renderEmail(data) {
77
- const baseUrl = `http://${config.ssrHost||process.env.SSR_HOST||'127.0.0.1'}`+
78
- `:${config.ssrPort||process.env.SSR_PORT||'8001'}`
79
-
80
- const encodedData = encodeURIComponent(JSON.stringify(data))
81
- const url = `${baseUrl}/_email/${data.action}/${data.contact}/${encodedData}`
82
- +`?sessionKey=${await authenticationKey.getValue()}`
83
- console.log("RENDER EMAIL", data, "URL", url)
79
+ async function renderEmailWithJuice(url) {
84
80
  const response = await got(url)
85
81
  let body = response.body
86
82
  console.log("BASE URL", baseUrl)
@@ -131,14 +127,38 @@ async function renderEmail(data) {
131
127
  }
132
128
  }
133
129
  console.log("IMAGES", Array.from(images.entries()))
134
- const imageAttachments = Array.from(images.entries()).map(([imagePath, contentId]) => {
130
+ const imageAttachments = await processImageAttachments(images, url)
131
+
132
+ email.attachments = email.attachments || []
133
+ email.attachments.push(...imageAttachments)
134
+
135
+ console.log("EMAIL", email)
136
+
137
+ return email
138
+ }
139
+
140
+ import fs from 'fs/promises'
141
+
142
+ async function processImageAttachments(images, url) {
143
+ return Promise.all(Array.from(images.entries()).map(async ([imagePath, contentId]) => {
144
+ if (imagePath.startsWith('http')) {
145
+ console.log("EXTERNAL IMAGE", imagePath)
146
+ return {
147
+ path: imagePath,
148
+ cid: contentId,
149
+ filename
150
+ }
151
+ }
135
152
  const imageUrl = new URL(imagePath, url)
136
153
  const file = path.resolve(publicDir, imageUrl.pathname.slice(1))
137
154
  const filename = path.basename(file)
138
- if(imagePath.startsWith('http')) {
139
- console.log("EXTERNAL IMAGE", imagePath)
155
+ console.log("LOCAL IMAGE", filename)
156
+ // check if file exists
157
+ if(fs.access(file).catch(() => false)) {
158
+ console.log("LOCAL IMAGE DOES NOT EXIST", file) // paths must be wrong, return external image
159
+ const imageUrl = new URL(imagePath, url)
140
160
  return {
141
- path: imagePath,
161
+ path: imageUrl.toString(),
142
162
  cid: contentId,
143
163
  filename
144
164
  }
@@ -148,14 +168,273 @@ async function renderEmail(data) {
148
168
  path: file,
149
169
  cid: contentId
150
170
  }
171
+ }))
172
+ }
173
+
174
+ async function renderEmailWithBrowser(url) {
175
+
176
+ return await runWithBrowser(async (browser) => {
177
+ const page = await browser.newPage()
178
+ console.log("RENDER EMAIL WITH BROWSER", url)
179
+ await page.goto(url)
180
+
181
+ const email = await page.evaluate(() => {
182
+ const ALLOWED_CSS_PROPERTIES = new Set([
183
+ 'background',
184
+ 'background-blend-mode',
185
+ 'background-clip',
186
+ 'background-color',
187
+ 'background-image',
188
+ 'background-origin',
189
+ 'background-position',
190
+ 'background-repeat',
191
+ 'background-size',
192
+ 'border',
193
+ 'border-bottom',
194
+ 'border-bottom-color',
195
+ 'border-bottom-left-radius',
196
+ 'border-bottom-right-radius',
197
+ 'border-bottom-style',
198
+ 'border-bottom-width',
199
+ 'border-collapse',
200
+ 'border-color',
201
+ 'border-left',
202
+ 'border-left-color',
203
+ 'border-left-style',
204
+ 'border-left-width',
205
+ 'border-radius',
206
+ 'border-right',
207
+ 'border-right-color',
208
+ 'border-right-style',
209
+ 'border-right-width',
210
+ 'border-spacing',
211
+ 'border-style',
212
+ 'border-top',
213
+ 'border-top-color',
214
+ 'border-top-left-radius',
215
+ 'border-top-right-radius',
216
+ 'border-top-style',
217
+ 'border-top-width',
218
+ 'border-width',
219
+ 'box-sizing',
220
+ 'color',
221
+ 'display',
222
+ 'float',
223
+ 'font',
224
+ 'font-family',
225
+ 'font-feature-settings',
226
+ 'font-size',
227
+ 'font-size-adjust',
228
+ 'font-stretch',
229
+ 'font-style',
230
+ 'font-weight',
231
+ 'height',
232
+ 'letter-spacing',
233
+ 'line-height',
234
+ 'list-style',
235
+ 'list-style-position',
236
+ 'list-style-type',
237
+ 'margin',
238
+ 'margin-bottom',
239
+ 'margin-left',
240
+ 'margin-right',
241
+ 'margin-top',
242
+ 'max-height',
243
+ 'max-width',
244
+ 'min-height',
245
+ 'min-width',
246
+ 'opacity',
247
+ 'outline',
248
+ 'outline-color',
249
+ 'outline-style',
250
+ 'outline-width',
251
+ 'overflow',
252
+ 'overflow-x',
253
+ 'overflow-y',
254
+ 'padding',
255
+ 'padding-bottom',
256
+ 'padding-left',
257
+ 'padding-right',
258
+ 'padding-top',
259
+ 'table-layout',
260
+ 'text-align',
261
+ 'text-decoration',
262
+ 'text-decoration-color',
263
+ 'text-decoration-line',
264
+ 'text-decoration-style',
265
+ 'text-indent',
266
+ 'text-overflow',
267
+ 'text-transform',
268
+ 'vertical-align',
269
+ 'white-space',
270
+ 'width',
271
+ 'word-break',
272
+ 'word-spacing',
273
+ 'word-wrap'
274
+ ])
275
+
276
+ function getCSSDefinedStylesWithLayers(element) {
277
+ const computedStyles = window.getComputedStyle(element)
278
+ const styles = {}
279
+ const layerRules = []
280
+
281
+ for (const sheet of document.styleSheets) {
282
+ try {
283
+ for (const rule of sheet.cssRules) {
284
+ if (rule instanceof CSSLayerBlockRule) {
285
+ // Przechodzi przez zdefiniowane warstwy
286
+ for (const subRule of rule.cssRules) {
287
+ if (subRule instanceof CSSStyleRule && element.matches(subRule.selectorText)) {
288
+ layerRules.push(subRule)
289
+ }
290
+ }
291
+ } else if (rule instanceof CSSStyleRule && element.matches(rule.selectorText)) {
292
+ layerRules.push(rule)
293
+ }
294
+ }
295
+ } catch (e) {
296
+ console.warn('Nie można odczytać stylów z:', sheet.href)
297
+ }
298
+ }
299
+
300
+ // Zastosowanie reguł w kolejności ich priorytetu
301
+ for (const rule of layerRules) {
302
+ for (const prop of rule.style) {
303
+ styles[prop] = computedStyles.getPropertyValue(prop)
304
+ }
305
+ }
306
+
307
+ return styles
308
+ }
309
+
310
+ function getFilteredComputedStyles(element) {
311
+ const definedStyles = getCSSDefinedStylesWithLayers(element)
312
+ const filteredStyles = {}
313
+
314
+ for (const prop in definedStyles) {
315
+ if (ALLOWED_CSS_PROPERTIES.has(prop)) {
316
+ filteredStyles[prop] = definedStyles[prop]
317
+ }
318
+ }
319
+
320
+ return filteredStyles
321
+ }
322
+
323
+ function cssStyleDeclarationToString(styles) {
324
+ return Object.entries(styles)
325
+ .map(([prop, value]) => `${prop}: ${value};`)
326
+ .join(' ')
327
+ }
328
+
329
+ function inlineImportantStyles(root) {
330
+ const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT)
331
+
332
+ while (treeWalker.nextNode()) {
333
+ const element = treeWalker.currentNode
334
+ const filteredStyles = getFilteredComputedStyles(element)
335
+ const inheritedStyles = getInheritedStyles(element)
336
+
337
+ // Usuwamy style, które są takie same jak u rodzica
338
+ for (const prop in inheritedStyles) {
339
+ if (filteredStyles[prop] === inheritedStyles[prop]) {
340
+ delete filteredStyles[prop]
341
+ }
342
+ }
343
+
344
+ const inlineStyle = cssStyleDeclarationToString(filteredStyles)
345
+ if (inlineStyle) {
346
+ element.setAttribute('style', inlineStyle)
347
+ }
348
+
349
+ // Usuwanie klas
350
+ element.removeAttribute('class')
351
+ }
352
+ }
353
+
354
+ function getInheritedStyles(element) {
355
+ const inheritedStyles = {}
356
+ let parent = element.parentElement
357
+
358
+ while (parent) {
359
+ const parentStyles = window.getComputedStyle(parent)
360
+ for (let i = 0; i < parentStyles.length; i++) {
361
+ const prop = parentStyles[i]
362
+ if (!inheritedStyles[prop]) {
363
+ inheritedStyles[prop] = parentStyles.getPropertyValue(prop)
364
+ }
365
+ }
366
+ parent = parent.parentElement
367
+ }
368
+
369
+ return inheritedStyles
370
+ }
371
+
372
+ // Get headers
373
+ const headersJson = document.querySelector('[data-headers]').textContent
374
+ const headers = JSON.parse(headersJson)
375
+
376
+ // Process HTML and text versions
377
+ const messageElements = document.querySelectorAll("[data-html],[data-text]")
378
+ const email = { ...headers }
379
+
380
+ for(let messageElement of messageElements) {
381
+ const toHtml = messageElement.getAttribute('data-html')
382
+ const toText = messageElement.getAttribute('data-text')
383
+
384
+ if(toHtml !== null) {
385
+ inlineImportantStyles(messageElement)
386
+ email.html = messageElement.outerHTML
387
+ }
388
+ if(toText !== null) {
389
+ email.text = messageElement.innerText
390
+ if(messageElement.tagName === 'PRE') {
391
+ const indentation = email.text.match(/^ */)[0]
392
+ const indentationRegex = new RegExp('\n' + indentation, 'g')
393
+ email.text = email.text.slice(indentation.length).replace(indentationRegex, '\n')
394
+ }
395
+ }
396
+ }
397
+
398
+ return email
399
+ })
400
+
401
+ // process element to remove unwanted attributes and extract images using jsdom
402
+ const dom = new JSDOM(email.html)
403
+ const images = new Map()
404
+ const messageElements = dom.window.document.querySelectorAll("[data-html]")
405
+ for(let messageElement of messageElements) {
406
+ processElement(messageElement, images)
407
+ }
408
+ email.html = dom.serialize()
409
+ dom.window.close()
410
+
411
+ console.log("IMAGES", Array.from(images.entries()))
412
+ const imageAttachments = await processImageAttachments(images, url)
413
+
414
+ email.attachments = email.attachments || []
415
+ email.attachments.push(...imageAttachments)
416
+
417
+ console.log("EMAIL", email)
418
+
419
+ return email
151
420
  })
421
+ }
152
422
 
153
- email.attachments = email.attachments || []
154
- email.attachments.push(...imageAttachments)
423
+ export { renderEmailWithJuice, renderEmailWithBrowser }
155
424
 
156
- console.log("EMAIL", email)
425
+ async function renderEmail(data) {
426
+ const baseUrl = `http://${config.ssrHost||process.env.SSR_HOST||'127.0.0.1'}`+
427
+ `:${config.ssrPort||process.env.SSR_PORT||'8001'}`
157
428
 
158
- return email
429
+ const encodedData = encodeURIComponent(JSON.stringify(data))
430
+ const url = `${baseUrl}/_email/${data.action}/${data.contact}/${encodedData}`
431
+ +`?sessionKey=${await authenticationKey.getValue()}`
432
+ console.log("RENDER EMAIL", data, "URL", url)
433
+ if(config.renderMethod === 'juice') {
434
+ return renderEmailWithJuice(url)
435
+ } else if(config.renderMethod === 'browser') {
436
+ return renderEmailWithBrowser(url)
437
+ }
159
438
  }
160
439
 
161
440
  export default renderEmail
@@ -0,0 +1,22 @@
1
+ import fs from 'fs/promises'
2
+
3
+ import "dotenv/config"
4
+ import nodemailer from 'nodemailer'
5
+
6
+ import { renderEmailWithJuice, renderEmailWithBrowser } from './render.js'
7
+ import config from './config.js'
8
+
9
+ const url = process.argv[2]
10
+
11
+ const email = await renderEmailWithBrowser(url)
12
+
13
+ //await fs.writeFile('email.html', email.html); process.exit(0)
14
+ console.log("SMTP CONFIG", config.smtp)
15
+
16
+ /// send email
17
+ const smtp = nodemailer.createTransport(config.smtp)
18
+
19
+
20
+ const result = await smtp.sendMail(email)
21
+
22
+ console.log(result)
package/send.js CHANGED
@@ -6,23 +6,9 @@ const app = App.app()
6
6
 
7
7
  import renderEmail from './render.js'
8
8
 
9
- const config = definition.config
9
+ import config from './config.js'
10
10
 
11
- const smtp = nodemailer.createTransport(config.transport || {
12
- host: config.host || process.env.SMTP_HOST,
13
- port: +(config.port || process.env.SMTP_PORT),
14
- auth: {
15
- user: (config.user || process.env.SMTP_USER),
16
- pass: (config.password || process.env.SMTP_PASSWORD)
17
- },
18
- secure: (config.secure || process.env.SMTP_SECURE) !== undefined
19
- ? !!((config.secure || process.env.SMTP_SECURE))
20
- : undefined, // secure:true for port 465, secure:false for port 587
21
- tls: {
22
- // do not fail on invalid certs
23
- rejectUnauthorized: !(config.ignoreTLS || process.env.SMTP_IGNORE_TLS)
24
- }
25
- })
11
+ const smtp = nodemailer.createTransport(config.smtp)
26
12
 
27
13
  const SentEmail = definition.model({
28
14
  name: "SentEmail",