kempo-smtp 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,58 @@
1
- # Temporary Holding Version
1
+ # kempo-smtp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Sends the emails kempo logs through any SMTP server, using [nodemailer](https://nodemailer.com): the mailbox that comes with your domain (Hostinger), Google Workspace, Amazon SES, Mailgun, Postmark, Brevo, or a mail server of your own. Kempo does not send email itself: it saves every email in its log (**Admin > Emails**) and hands it to an email extension through the `email:send` hook. This is that extension for SMTP.
4
+
5
+ ## Setup
6
+
7
+ 1. Install it from Admin > Extensions.
8
+ 2. On **Admin > Settings**, open the **SMTP** tab and fill in the server (see below), then **Save settings**. The password is stored encrypted and never shown again.
9
+ 3. On the **System** tab, set **Email from** to the mailbox address, such as `Your Shop <orders@yourdomain.com>`, if you want a display name. SMTP servers only let you send as the mailbox you sign in with (or its aliases). While Email from is not set (still `noreply@example.com`), kempo-smtp sends as the mailbox you sign in with, so a plain setup works without it.
10
+ 4. On **Admin > Emails**, press **Send a test email** to check it.
11
+
12
+ `SETTINGS_ENCRYPTION_KEY` must be set on the server (kempo makes one for you the first time it starts), because the password is stored encrypted.
13
+
14
+ ### Hostinger (the email that comes with a domain)
15
+
16
+ Create the mailbox in Hostinger's panel (Emails), then use its **SMTP** settings from the mailbox's configuration page. They are normally:
17
+
18
+ | Setting | Value |
19
+ |---|---|
20
+ | Host | `smtp.hostinger.com` |
21
+ | Port | `465` with **Secure from the start** on (or `587` with it off) |
22
+ | User | the full mailbox address, `orders@yourdomain.com` |
23
+ | Password | the mailbox password |
24
+
25
+ The domain's mail (MX) records must point at Hostinger's mail servers, and the SPF and DKIM records Hostinger shows for the mailbox must be in the domain's DNS: if the domain's DNS is at Hostinger this is automatic, otherwise add them where the DNS is. Your website's own A record (to a VPS) does not affect mail. Check the sending limits of your plan: a free mailbox allows only so many emails an hour.
26
+
27
+ ### Other providers
28
+
29
+ | Provider | Host | Port | User |
30
+ |---|---|---|---|
31
+ | Google Workspace | `smtp.gmail.com` | 465 | the mailbox, with an app password |
32
+ | Amazon SES | `email-smtp.<region>.amazonaws.com` | 465 | the SES SMTP user |
33
+ | Mailgun | `smtp.mailgun.org` | 465 | `postmaster@yourdomain` |
34
+ | A Postfix on the same server | `localhost` | 25 | none (you manage deliverability yourself) |
35
+
36
+ ## What it does
37
+
38
+ For each email kempo asks to send, the handler connects to the server, signs in, sends the message with the HTML and a plain-text version made from it, and answers kempo with `{ sent: true, provider: 'smtp', messageId }` or `{ sent: false, error }`. Kempo records the result in the log, tries a failed email again (the **Email max retries** setting, 1 by default), and keeps the log for the retention settings.
39
+
40
+ Failures are reported in plain words: the server was not found, nothing is listening at host:port, the username or password was refused, the server refused the address (with its own reason), or the connection could not be made (with a hint to check whether it is meant to be secure). Connecting times out after 20 seconds, so a wrong host cannot hold up a request.
41
+
42
+ **Secure from the start** is for port 465, where the connection is encrypted before anything is said. With it off (port 587) the connection starts plain and upgrades to an encrypted one when the server offers it (nodemailer's default). It does not insist on that, so a server that offers no encryption at all, such as a Postfix on the same machine, still works; for anything across the internet use 465 or a server that offers STARTTLS.
43
+
44
+ With no host saved the handler answers that it did not send, so if another email extension is installed (kempo-resend), it gets the email instead. The first one to send it ends the chain.
45
+
46
+ ## Settings
47
+
48
+ | Setting | Default | Meaning |
49
+ |---|---|---|
50
+ | `smtp_host` | — | The address of the SMTP server |
51
+ | `smtp_port` | `465` | The port |
52
+ | `smtp_secure` | `true` | Secure from the start (465); off for 587 |
53
+ | `smtp_user` | — | The username, usually the mailbox address. Empty for a server that needs none |
54
+ | `smtp_password` | — | Secret. The password |
55
+
56
+ ## Tests
57
+
58
+ `npm test` starts a real SMTP server of its own on a free port and sends to it: a delivered message (headers, HTML and text parts, sender and recipient), a wrong password, a refused address, a server that is not there, a server that needs no sign-in, and the plain-text conversion. Nothing leaves the machine.
@@ -0,0 +1 @@
1
+ <content id="kempo-smtp-settings-tab" name="SMTP settings tab" owner="kempo-smtp" enabled="true" locked="true" location="admin-settings-tabs" priority="20"><k-settings-tab name="smtp" label="SMTP" position="410"><p class="tc-muted mb">Sends the site's email through an SMTP server, such as the mailbox that comes with your domain. The address emails are sent from is the Email from setting on the System tab, and it must be that mailbox's address (or one of its aliases). After saving, send a test email from <a href="/admin/emails">Emails</a>.</p><k-settings-panel owner="kempo-smtp"></k-settings-panel></k-settings-tab></content>
package/hooks/send.js ADDED
@@ -0,0 +1,18 @@
1
+ import { getSetting } from 'kempo/server/sdk.js';
2
+ import { sendWithSmtp } from '../server/utils/smtp.js';
3
+
4
+ /*
5
+ The `email:send` handler: sends the email through the SMTP server in the settings and says how it went (kempo keeps the log). With no
6
+ host saved it answers that it did not send, so another email extension, if there is one, gets its turn.
7
+ */
8
+ export default async ({ email }) => {
9
+ const read = async (name, fallback) => (await getSetting('kempo-smtp', name, fallback))[1];
10
+ const settings = {
11
+ host: String(await read('smtp_host', '')).trim(),
12
+ port: Number(await read('smtp_port', 465)),
13
+ secure: (await read('smtp_secure', true)) === true,
14
+ user: String(await read('smtp_user', '')).trim(),
15
+ password: String(await read('smtp_password', '')),
16
+ };
17
+ return sendWithSmtp({ settings, email });
18
+ };
package/install.js ADDED
@@ -0,0 +1,8 @@
1
+ /* Nothing to create: the settings come from kempo-config.json. Says what is still to do, and warns when kempo cannot store the password. */
2
+ export default async () => {
3
+ if(!process.env.SETTINGS_ENCRYPTION_KEY){
4
+ console.warn('[kempo-smtp] SETTINGS_ENCRYPTION_KEY is not set, so the SMTP password cannot be stored. Start the server once to have kempo make one, then reinstall this extension.');
5
+ return;
6
+ }
7
+ console.log('[kempo-smtp] Installed. Add the SMTP host, port, user and password on the SMTP tab of the Settings page, and set the Email from address to the mailbox address.');
8
+ };
@@ -0,0 +1,39 @@
1
+ {
2
+ "docs": "https://github.com/dustinpoissant/kempo-smtp#readme",
3
+ "git": "https://github.com/dustinpoissant/kempo-smtp",
4
+ "hooks": {
5
+ "email:send": "./hooks/send.js"
6
+ },
7
+ "settings": [
8
+ {
9
+ "name": "smtp_host",
10
+ "value": "",
11
+ "type": "string",
12
+ "description": "The address of your SMTP server, such as smtp.hostinger.com. Your email provider lists it with the mailbox settings"
13
+ },
14
+ {
15
+ "name": "smtp_port",
16
+ "value": "465",
17
+ "type": "number",
18
+ "description": "The SMTP port: 465 for a connection that is secure from the start, 587 for one that upgrades to secure"
19
+ },
20
+ {
21
+ "name": "smtp_secure",
22
+ "value": "true",
23
+ "type": "boolean",
24
+ "description": "Secure from the start (turn on for port 465). Turn off for port 587, which upgrades to a secure connection after connecting"
25
+ },
26
+ {
27
+ "name": "smtp_user",
28
+ "value": "",
29
+ "type": "string",
30
+ "description": "The username to sign in with, which is usually the full mailbox address such as orders@yourdomain.com. Leave empty for a server that needs none"
31
+ },
32
+ {
33
+ "name": "smtp_password",
34
+ "value": "",
35
+ "type": "secret",
36
+ "description": "The password of that mailbox. Stored encrypted and never shown again"
37
+ }
38
+ ]
39
+ }
package/package.json CHANGED
@@ -1,6 +1,38 @@
1
1
  {
2
2
  "name": "kempo-smtp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Sends the emails kempo logs through any SMTP server: a Hostinger or Google Workspace mailbox, Amazon SES, Mailgun, or a mail server of your own.",
5
+ "license": "MIT",
6
+ "author": "Dustin Poissant",
7
+ "type": "module",
8
+ "scripts": {
9
+ "test": "node --test"
10
+ },
11
+ "files": [
12
+ "admin",
13
+ "hooks",
14
+ "server",
15
+ "install.js",
16
+ "update.js",
17
+ "uninstall.js",
18
+ "kempo-config.json"
19
+ ],
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/dustinpoissant/kempo-smtp.git"
23
+ },
24
+ "dependencies": {
25
+ "nodemailer": "^7.0.0"
26
+ },
27
+ "devDependencies": {
28
+ "smtp-server": "^3.13.0"
29
+ },
30
+ "peerDependencies": {
31
+ "kempo": ">=4.8.0"
32
+ },
33
+ "exports": {
34
+ "./install": "./install.js",
35
+ "./update": "./update.js",
36
+ "./uninstall": "./uninstall.js"
37
+ }
38
+ }
@@ -0,0 +1,76 @@
1
+ import nodemailer from 'nodemailer';
2
+
3
+ /*
4
+ Sending one email through an SMTP server with nodemailer. The settings and the email are given, and so is `createTransport`, so a test
5
+ can use a real SMTP server of its own and nothing else is needed. It never throws: it resolves to what an email:send handler answers,
6
+ `{ sent: true, provider, messageId }` or `{ sent: false, error }` (with `skipped: true` when no host is set up, so kempo knows it did not try).
7
+
8
+ `secure: true` connects with TLS straight away (port 465, what most providers call SSL). `secure: false` connects plainly and upgrades with
9
+ STARTTLS when the server offers it (port 587); the password is not sent before that unless the server cannot do TLS at all.
10
+ */
11
+ const TIMEOUT_MS = 20 * 1000;
12
+
13
+ const ENTITIES = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#39;': '\'', '&nbsp;': ' ', '&times;': 'x', '&middot;': '-' };
14
+
15
+ /* A plain-text version of the HTML for mail programs that show text (and spam filters that like to see one). */
16
+ export const htmlToText = html => String(html ?? '')
17
+ .replace(/<(style|script)[\s\S]*?<\/\1>/gi, '')
18
+ .replace(/<br\s*\/?>/gi, '\n')
19
+ .replace(/<\/(p|div|h[1-6]|tr|li|blockquote|table)>/gi, '\n')
20
+ .replace(/<a\b[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, (match, href, text) => (text.replace(/<[^>]*>/g, '').trim() === href ? href : `${text.replace(/<[^>]*>/g, '').trim()} (${href})`))
21
+ .replace(/<[^>]*>/g, '')
22
+ .replace(/&(?:amp|lt|gt|quot|#39|nbsp|times|middot);/g, entity => ENTITIES[entity])
23
+ .replace(/[ \t]+\n/g, '\n')
24
+ .replace(/\n{3,}/g, '\n\n')
25
+ .trim();
26
+
27
+ /* The sentence for a failure, in plain words, from what nodemailer reports. */
28
+ export const explain = (error, { host, port }, from = '') => {
29
+ const reason = String(error?.response ?? error?.message ?? 'no reason given').replace(/\s+/g, ' ').trim();
30
+ switch(error?.code){
31
+ case 'EAUTH': return `The SMTP server did not accept the username or password (${reason})`;
32
+ case 'ECONNREFUSED': return `Nothing is listening at ${host}:${port}. Check the host and port`;
33
+ case 'ENOTFOUND': case 'EDNS': return `The host ${host} was not found`;
34
+ case 'ETIMEDOUT': case 'ESOCKET': case 'ECONNECTION': case 'ECONNRESET': return `Could not connect to ${host}:${port} (${reason}). Check the host, the port and whether the connection is meant to be secure`;
35
+ case 'EENVELOPE': return /sender|from|5\.7\.|domain/i.test(reason) && from
36
+ ? `The SMTP server refused the sender address ${from} (${reason}). Set Email from, on the System tab of the Settings page, to the mailbox address or one of its aliases`
37
+ : `The SMTP server refused the address: ${reason}`;
38
+ case 'EMESSAGE': return `The SMTP server refused the message: ${reason}`;
39
+ default: return `SMTP: ${reason}`;
40
+ }
41
+ };
42
+
43
+ /* The sender to use: the site's, or when the site has not set one (a placeholder), the mailbox signed in with, which is what an SMTP server accepts. */
44
+ export const senderFor = (email, settings) => (email.fromIsDefault && /^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(settings?.user ?? '') ? settings.user : email.from);
45
+
46
+ export const validate = settings => {
47
+ if(!settings?.host) return 'No SMTP server is set up. Add the host on the SMTP tab of the Settings page';
48
+ if(!Number.isInteger(settings.port) || settings.port < 1 || settings.port > 65535) return 'The SMTP port must be a number from 1 to 65535';
49
+ return null;
50
+ };
51
+
52
+ export const sendWithSmtp = async ({ settings, email, createTransport = options => nodemailer.createTransport(options) }) => {
53
+ const problem = validate(settings);
54
+ if(problem) return { sent: false, ...(settings?.host ? {} : { skipped: true }), error: problem };
55
+ const from = senderFor(email, settings);
56
+
57
+ const transport = createTransport({
58
+ host: settings.host,
59
+ port: settings.port,
60
+ secure: settings.secure,
61
+ ...(settings.user ? { auth: { user: settings.user, pass: settings.password } } : {}),
62
+ connectionTimeout: TIMEOUT_MS,
63
+ greetingTimeout: TIMEOUT_MS,
64
+ socketTimeout: TIMEOUT_MS * 2,
65
+ });
66
+
67
+ try {
68
+ const info = await transport.sendMail({ from, to: email.to, subject: email.subject, html: email.html, text: htmlToText(email.html) });
69
+ if(Array.isArray(info.accepted) && info.accepted.length === 0) return { sent: false, error: `The SMTP server did not accept ${email.to}${info.rejected?.length ? ` (rejected: ${info.rejected.join(', ')})` : ''}` };
70
+ return { sent: true, provider: 'smtp', messageId: info.messageId };
71
+ } catch(error) {
72
+ return { sent: false, error: explain(error, settings, from) };
73
+ } finally {
74
+ transport.close?.();
75
+ }
76
+ };
package/uninstall.js ADDED
@@ -0,0 +1,2 @@
1
+ /* The hook and the setting are removed with the extension, so there is nothing to undo. */
2
+ export default async () => {};
package/update.js ADDED
@@ -0,0 +1,6 @@
1
+ import install from './install.js';
2
+
3
+ /* An update repeats the install, which only says what is left to set up. */
4
+ export default async () => {
5
+ await install();
6
+ };