@uniora/mailer-smtp 0.3.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/LICENSE +91 -0
- package/README.md +41 -0
- package/dist/config.d.ts +39 -0
- package/dist/config.js +65 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +4 -0
- package/dist/locales.d.ts +22 -0
- package/dist/locales.js +98 -0
- package/dist/sender.d.ts +50 -0
- package/dist/sender.js +111 -0
- package/dist/templates.d.ts +36 -0
- package/dist/templates.js +164 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
Required Notice: Copyright 2026 CodexSploitx (https://github.com/CodexSploitx/uniora)
|
|
2
|
+
|
|
3
|
+
# PolyForm Shield License 1.0.0
|
|
4
|
+
|
|
5
|
+
<https://polyformproject.org/licenses/shield/1.0.0>
|
|
6
|
+
|
|
7
|
+
## Acceptance
|
|
8
|
+
|
|
9
|
+
In order to get any license under these terms, you must agree to them as both strict obligations and conditions to all your licenses.
|
|
10
|
+
|
|
11
|
+
## Copyright License
|
|
12
|
+
|
|
13
|
+
The licensor grants you a copyright license for the software to do everything you might do with the software that would otherwise infringe the licensor's copyright in it for any permitted purpose. However, you may only distribute the software according to [Distribution License](#distribution-license) and make changes or new works based on the software according to [Changes and New Works License](#changes-and-new-works-license).
|
|
14
|
+
|
|
15
|
+
## Distribution License
|
|
16
|
+
|
|
17
|
+
The licensor grants you an additional copyright license to distribute copies of the software. Your license to distribute covers distributing the software with changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
|
|
18
|
+
|
|
19
|
+
## Notices
|
|
20
|
+
|
|
21
|
+
You must ensure that anyone who gets a copy of any part of the software from you also gets a copy of these terms or the URL for them above, as well as copies of any plain-text lines beginning with `Required Notice:` that the licensor provided with the software. For example:
|
|
22
|
+
|
|
23
|
+
> Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
|
|
24
|
+
|
|
25
|
+
## Changes and New Works License
|
|
26
|
+
|
|
27
|
+
The licensor grants you an additional copyright license to make changes and new works based on the software for any permitted purpose.
|
|
28
|
+
|
|
29
|
+
## Patent License
|
|
30
|
+
|
|
31
|
+
The licensor grants you a patent license for the software that covers patent claims the licensor can license, or becomes able to license, that you would infringe by using the software.
|
|
32
|
+
|
|
33
|
+
## Noncompete
|
|
34
|
+
|
|
35
|
+
Any purpose is a permitted purpose, except for providing any product that competes with the software or any product the licensor or any of its affiliates provides using the software.
|
|
36
|
+
|
|
37
|
+
## Competition
|
|
38
|
+
|
|
39
|
+
Goods and services compete even when they provide functionality through different kinds of interfaces or for different technical platforms. Applications can compete with services, libraries with plugins, frameworks with development tools, and so on, even if they're written in different programming languages or for different computer architectures. Goods and services compete even when provided free of charge. If you market a product as a practical substitute for the software or another product, it definitely competes.
|
|
40
|
+
|
|
41
|
+
## New Products
|
|
42
|
+
|
|
43
|
+
If you are using the software to provide a product that does not compete, but the licensor or any of its affiliates brings your product into competition by providing a new version of the software or another product using the software, you may continue using versions of the software available under these terms beforehand to provide your competing product, but not any later versions.
|
|
44
|
+
|
|
45
|
+
## Discontinued Products
|
|
46
|
+
|
|
47
|
+
You may begin using the software to compete with a product or service that the licensor or any of its affiliates has stopped providing, unless the licensor includes a plain-text line beginning with `Licensor Line of Business:` with the software that mentions that line of business. For example:
|
|
48
|
+
|
|
49
|
+
> Licensor Line of Business: YoyodyneCMS Content Management System (http://example.com/cms)
|
|
50
|
+
|
|
51
|
+
## Sales of Business
|
|
52
|
+
|
|
53
|
+
If the licensor or any of its affiliates sells a line of business developing the software or using the software to provide a product, the buyer can also enforce [Noncompete](#noncompete) for that product.
|
|
54
|
+
|
|
55
|
+
## Fair Use
|
|
56
|
+
|
|
57
|
+
You may have "fair use" rights for the software under the law. These terms do not limit them.
|
|
58
|
+
|
|
59
|
+
## No Other Rights
|
|
60
|
+
|
|
61
|
+
These terms do not allow you to sublicense or transfer any of your licenses to anyone else, or prevent the licensor from granting licenses to anyone else. These terms do not imply any other licenses.
|
|
62
|
+
|
|
63
|
+
## Patent Defense
|
|
64
|
+
|
|
65
|
+
If you make any written claim that the software infringes or contributes to infringement of any patent, your patent license for the software granted under these terms ends immediately. If your company makes such a claim, your patent license ends immediately for work on behalf of your company.
|
|
66
|
+
|
|
67
|
+
## Violations
|
|
68
|
+
|
|
69
|
+
The first time you are notified in writing that you have violated any of these terms, or done anything with the software not covered by your licenses, your licenses can nonetheless continue if you come into full compliance with these terms, and take practical steps to correct past violations, within 32 days of receiving notice. Otherwise, all your licenses end immediately.
|
|
70
|
+
|
|
71
|
+
## No Liability
|
|
72
|
+
|
|
73
|
+
***As far as the law allows, the software comes as is, without any warranty or condition, and the licensor will not be liable to you for any damages arising out of these terms or the use or nature of the software, under any kind of legal claim.***
|
|
74
|
+
|
|
75
|
+
## Definitions
|
|
76
|
+
|
|
77
|
+
The **licensor** is the individual or entity offering these terms, and the **software** is the software the licensor makes available under these terms.
|
|
78
|
+
|
|
79
|
+
A **product** can be a good or service, or a combination of them.
|
|
80
|
+
|
|
81
|
+
**You** refers to the individual or entity agreeing to these terms.
|
|
82
|
+
|
|
83
|
+
**Your company** is any legal entity, sole proprietorship, or other kind of organization that you work for, plus all its affiliates.
|
|
84
|
+
|
|
85
|
+
**Affiliates** means the other organizations than an organization has control over, is under the control of, or is under common control with.
|
|
86
|
+
|
|
87
|
+
**Control** means ownership of substantially all the assets of an entity, or the power to direct its management and policies by vote, contract, or otherwise. Control can be direct or indirect.
|
|
88
|
+
|
|
89
|
+
**Your licenses** are all the licenses granted to you for the software under these terms.
|
|
90
|
+
|
|
91
|
+
**Use** means anything you do with the software requiring one of your licenses.
|
package/README.md
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# @uniora/mailer-smtp
|
|
2
|
+
|
|
3
|
+
SMTP delivery for [UNIORA](https://github.com/CodexSploitx/uniora) invitations. It implements `InvitationSender` from `@uniora/core`, reads its settings from `UNIORA_SMTP_*` environment variables and ships professional, responsive, dark-mode-aware templates in English, Spanish, Portuguese, French, German and Italian.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createInvitationService } from "@uniora/core";
|
|
7
|
+
import { createSmtpInvitationSenderFromEnv } from "@uniora/mailer-smtp";
|
|
8
|
+
|
|
9
|
+
const sender = createSmtpInvitationSenderFromEnv(process.env, {
|
|
10
|
+
brandName: "Acme",
|
|
11
|
+
brandColor: "#0f766e",
|
|
12
|
+
logoUrl: "https://acme.com/logo.png",
|
|
13
|
+
supportEmail: "help@acme.com",
|
|
14
|
+
resolveInviterName: async (identity) => (await db.users.find(identity.subject))?.name,
|
|
15
|
+
});
|
|
16
|
+
await sender.verify(); // optional startup check: connects and authenticates
|
|
17
|
+
|
|
18
|
+
const invitations = createInvitationService({ storage, sender, acceptUrl: (t) => `https://app.acme.com/invite/${t}` });
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
| Variable | Default | |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `UNIORA_SMTP_HOST` | — | **required**, bare host name |
|
|
26
|
+
| `UNIORA_SMTP_PORT` | `587` | `465` uses implicit TLS, otherwise STARTTLS |
|
|
27
|
+
| `UNIORA_SMTP_SECURE` | `true` only on 465 | force implicit TLS |
|
|
28
|
+
| `UNIORA_SMTP_USER` / `UNIORA_SMTP_PASS` | — | both or neither |
|
|
29
|
+
| `UNIORA_SMTP_FROM` | — | **required**, `Name <addr>` or `addr` |
|
|
30
|
+
| `UNIORA_SMTP_REPLY_TO` | — | optional |
|
|
31
|
+
| `UNIORA_SMTP_REQUIRE_TLS` | `true` | refuse to send unencrypted |
|
|
32
|
+
| `UNIORA_SMTP_TLS_REJECT_UNAUTHORIZED` | `true` | verify the server certificate |
|
|
33
|
+
|
|
34
|
+
Anything missing or malformed throws one `SmtpConfigError` listing every problem — at startup, not on the first invitation.
|
|
35
|
+
|
|
36
|
+
## Behaviour
|
|
37
|
+
|
|
38
|
+
- **Retries, timeouts and bookkeeping** are done by `createInvitationService`; this package classifies failures: bad credentials and any `5xx`/rejected recipient are permanent (no retry), network errors and `4xx` are transient.
|
|
39
|
+
- **Templates are safe by construction**: every interpolated value is escaped, the Subject is forced onto one line, the link must be `http(s)`, the logo `https`, the brand color a `#rrggbb`.
|
|
40
|
+
- **Own template?** Pass `template: (message, context) => ({ subject, html, text })`. **Own provider?** Implement `InvitationSender` yourself — nothing else in UNIORA changes.
|
|
41
|
+
- Each message carries `Auto-Submitted: auto-generated` and an `X-Entity-Ref-ID` (invitation id + attempt) for correlating provider logs. The token never appears in headers or logs.
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMTP settings, read from the environment. UNIORA never ships credentials and
|
|
3
|
+
* never guesses: the mailer refuses to start until the minimum is present, and
|
|
4
|
+
* says everything that is wrong in one error.
|
|
5
|
+
*
|
|
6
|
+
* UNIORA_SMTP_HOST required e.g. smtp.postmarkapp.com
|
|
7
|
+
* UNIORA_SMTP_PORT 587 465 = implicit TLS, 587/25 = STARTTLS
|
|
8
|
+
* UNIORA_SMTP_SECURE auto "true" for implicit TLS (default: true only on 465)
|
|
9
|
+
* UNIORA_SMTP_USER optional with PASS; both or neither
|
|
10
|
+
* UNIORA_SMTP_PASS optional
|
|
11
|
+
* UNIORA_SMTP_FROM required "Acme <no-reply@acme.com>" or a bare address
|
|
12
|
+
* UNIORA_SMTP_REPLY_TO optional
|
|
13
|
+
* UNIORA_SMTP_REQUIRE_TLS true refuse to send over a plain-text connection
|
|
14
|
+
* UNIORA_SMTP_TLS_REJECT_UNAUTHORIZED true verify the server certificate
|
|
15
|
+
*/
|
|
16
|
+
export interface SmtpConfig {
|
|
17
|
+
host: string;
|
|
18
|
+
port: number;
|
|
19
|
+
/** Implicit TLS (SMTPS). When false the connection is upgraded with STARTTLS. */
|
|
20
|
+
secure: boolean;
|
|
21
|
+
auth?: {
|
|
22
|
+
user: string;
|
|
23
|
+
pass: string;
|
|
24
|
+
};
|
|
25
|
+
from: string;
|
|
26
|
+
replyTo?: string;
|
|
27
|
+
/** Fail instead of falling back to an unencrypted session. Ignored when `secure`. */
|
|
28
|
+
requireTLS: boolean;
|
|
29
|
+
rejectUnauthorized: boolean;
|
|
30
|
+
}
|
|
31
|
+
export declare class SmtpConfigError extends Error {
|
|
32
|
+
readonly problems: readonly string[];
|
|
33
|
+
constructor(problems: readonly string[]);
|
|
34
|
+
}
|
|
35
|
+
type Env = Readonly<Record<string, string | undefined>>;
|
|
36
|
+
/** A header-safe address: no line breaks (header injection), one mailbox. */
|
|
37
|
+
export declare function isSafeMailbox(value: string): boolean;
|
|
38
|
+
export declare function loadSmtpConfig(env?: Env): SmtpConfig;
|
|
39
|
+
export {};
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export class SmtpConfigError extends Error {
|
|
2
|
+
problems;
|
|
3
|
+
constructor(problems) {
|
|
4
|
+
super(`Invalid SMTP configuration:\n${problems.map((problem) => ` - ${problem}`).join("\n")}`);
|
|
5
|
+
this.problems = problems;
|
|
6
|
+
this.name = "SmtpConfigError";
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
function bool(value, fallback, name, problems) {
|
|
10
|
+
if (value === undefined || value.trim() === "")
|
|
11
|
+
return fallback;
|
|
12
|
+
const normalized = value.trim().toLowerCase();
|
|
13
|
+
if (["true", "1", "yes"].includes(normalized))
|
|
14
|
+
return true;
|
|
15
|
+
if (["false", "0", "no"].includes(normalized))
|
|
16
|
+
return false;
|
|
17
|
+
problems.push(`${name} must be "true" or "false" (got "${value}").`);
|
|
18
|
+
return fallback;
|
|
19
|
+
}
|
|
20
|
+
/** A header-safe address: no line breaks (header injection), one mailbox. */
|
|
21
|
+
export function isSafeMailbox(value) {
|
|
22
|
+
return !/[\r\n\u0000]/.test(value) && /^(?:[^<>@]*<)?[^\s<>@,;]+@[^\s<>@,;]+\.[^\s<>@,;]+>?$/.test(value.trim());
|
|
23
|
+
}
|
|
24
|
+
export function loadSmtpConfig(env = process.env) {
|
|
25
|
+
const problems = [];
|
|
26
|
+
const get = (key) => env[key]?.trim() || undefined;
|
|
27
|
+
const host = get("UNIORA_SMTP_HOST");
|
|
28
|
+
if (!host)
|
|
29
|
+
problems.push("UNIORA_SMTP_HOST is required.");
|
|
30
|
+
else if (/[\s/]/.test(host))
|
|
31
|
+
problems.push("UNIORA_SMTP_HOST must be a bare host name (no scheme, path or spaces).");
|
|
32
|
+
const portText = get("UNIORA_SMTP_PORT");
|
|
33
|
+
const port = portText === undefined ? 587 : Number(portText);
|
|
34
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
35
|
+
problems.push(`UNIORA_SMTP_PORT must be an integer between 1 and 65535 (got "${portText}").`);
|
|
36
|
+
}
|
|
37
|
+
const secure = bool(env.UNIORA_SMTP_SECURE, port === 465, "UNIORA_SMTP_SECURE", problems);
|
|
38
|
+
const requireTLS = bool(env.UNIORA_SMTP_REQUIRE_TLS, true, "UNIORA_SMTP_REQUIRE_TLS", problems);
|
|
39
|
+
const rejectUnauthorized = bool(env.UNIORA_SMTP_TLS_REJECT_UNAUTHORIZED, true, "UNIORA_SMTP_TLS_REJECT_UNAUTHORIZED", problems);
|
|
40
|
+
const user = get("UNIORA_SMTP_USER");
|
|
41
|
+
const pass = env.UNIORA_SMTP_PASS === undefined || env.UNIORA_SMTP_PASS === "" ? undefined : env.UNIORA_SMTP_PASS;
|
|
42
|
+
if ((user === undefined) !== (pass === undefined)) {
|
|
43
|
+
problems.push("UNIORA_SMTP_USER and UNIORA_SMTP_PASS must be set together (or both left empty).");
|
|
44
|
+
}
|
|
45
|
+
const from = get("UNIORA_SMTP_FROM");
|
|
46
|
+
if (!from)
|
|
47
|
+
problems.push("UNIORA_SMTP_FROM is required (e.g. \"Acme <no-reply@acme.com>\").");
|
|
48
|
+
else if (!isSafeMailbox(from))
|
|
49
|
+
problems.push("UNIORA_SMTP_FROM must be a single valid address, optionally with a display name.");
|
|
50
|
+
const replyTo = get("UNIORA_SMTP_REPLY_TO");
|
|
51
|
+
if (replyTo && !isSafeMailbox(replyTo))
|
|
52
|
+
problems.push("UNIORA_SMTP_REPLY_TO must be a single valid address.");
|
|
53
|
+
if (problems.length > 0)
|
|
54
|
+
throw new SmtpConfigError(problems);
|
|
55
|
+
return {
|
|
56
|
+
host: host,
|
|
57
|
+
port,
|
|
58
|
+
secure,
|
|
59
|
+
auth: user !== undefined && pass !== undefined ? { user, pass } : undefined,
|
|
60
|
+
from: from,
|
|
61
|
+
replyTo,
|
|
62
|
+
requireTLS,
|
|
63
|
+
rejectUnauthorized,
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type { SmtpConfig } from "./config.js";
|
|
2
|
+
export { isSafeMailbox, loadSmtpConfig, SmtpConfigError } from "./config.js";
|
|
3
|
+
export type { EmailLocale, InvitationStrings } from "./locales.js";
|
|
4
|
+
export { DEFAULT_LOCALE, resolveLocale, STRINGS } from "./locales.js";
|
|
5
|
+
export type { InvitationTemplate, InvitationTemplateContext, InvitationTemplateOptions, RenderedEmail, } from "./templates.js";
|
|
6
|
+
export { escapeHtml, renderInvitationEmail } from "./templates.js";
|
|
7
|
+
export type { MailTransport, SmtpInvitationSender, SmtpInvitationSenderOptions } from "./sender.js";
|
|
8
|
+
export { createSmtpInvitationSender, createSmtpInvitationSenderFromEnv, isPermanentSmtpFailure } from "./sender.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { isSafeMailbox, loadSmtpConfig, SmtpConfigError } from "./config.js";
|
|
2
|
+
export { DEFAULT_LOCALE, resolveLocale, STRINGS } from "./locales.js";
|
|
3
|
+
export { escapeHtml, renderInvitationEmail } from "./templates.js";
|
|
4
|
+
export { createSmtpInvitationSender, createSmtpInvitationSenderFromEnv, isPermanentSmtpFailure } from "./sender.js";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type EmailLocale = "en" | "es" | "pt" | "fr" | "de" | "it";
|
|
2
|
+
export interface InvitationStrings {
|
|
3
|
+
/** `{org}` is replaced (and escaped by the renderer). */
|
|
4
|
+
subject: string;
|
|
5
|
+
preheader: string;
|
|
6
|
+
heading: string;
|
|
7
|
+
/** With a known inviter: `{inviter}` and `{org}`. */
|
|
8
|
+
introWithInviter: string;
|
|
9
|
+
intro: string;
|
|
10
|
+
roleOne: string;
|
|
11
|
+
roleMany: string;
|
|
12
|
+
button: string;
|
|
13
|
+
expires: string;
|
|
14
|
+
fallback: string;
|
|
15
|
+
ignore: string;
|
|
16
|
+
sentBy: string;
|
|
17
|
+
help: string;
|
|
18
|
+
}
|
|
19
|
+
export declare const DEFAULT_LOCALE: EmailLocale;
|
|
20
|
+
export declare const STRINGS: Record<EmailLocale, InvitationStrings>;
|
|
21
|
+
/** `es-MX` → `es`, `PT_br` → `pt`; anything unsupported falls back to the default. */
|
|
22
|
+
export declare function resolveLocale(requested: string | undefined, fallback?: EmailLocale): EmailLocale;
|
package/dist/locales.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
export const DEFAULT_LOCALE = "en";
|
|
2
|
+
export const STRINGS = {
|
|
3
|
+
en: {
|
|
4
|
+
subject: "You're invited to join {org}",
|
|
5
|
+
preheader: "Accept your invitation to join {org}.",
|
|
6
|
+
heading: "You're invited to join {org}",
|
|
7
|
+
introWithInviter: "{inviter} has invited you to join {org}.",
|
|
8
|
+
intro: "You have been invited to join {org}.",
|
|
9
|
+
roleOne: "Your role",
|
|
10
|
+
roleMany: "Your roles",
|
|
11
|
+
button: "Accept invitation",
|
|
12
|
+
expires: "This invitation expires on {date}.",
|
|
13
|
+
fallback: "If the button doesn't work, copy and paste this link into your browser:",
|
|
14
|
+
ignore: "Weren't expecting this? You can safely ignore this e-mail — nothing happens until you accept.",
|
|
15
|
+
sentBy: "Sent by {brand}",
|
|
16
|
+
help: "Questions? Contact {support}.",
|
|
17
|
+
},
|
|
18
|
+
es: {
|
|
19
|
+
subject: "Te invitaron a unirte a {org}",
|
|
20
|
+
preheader: "Acepta tu invitación para unirte a {org}.",
|
|
21
|
+
heading: "Te invitaron a unirte a {org}",
|
|
22
|
+
introWithInviter: "{inviter} te ha invitado a unirte a {org}.",
|
|
23
|
+
intro: "Has sido invitado/a a unirte a {org}.",
|
|
24
|
+
roleOne: "Tu rol",
|
|
25
|
+
roleMany: "Tus roles",
|
|
26
|
+
button: "Aceptar invitación",
|
|
27
|
+
expires: "Esta invitación vence el {date}.",
|
|
28
|
+
fallback: "Si el botón no funciona, copia y pega este enlace en tu navegador:",
|
|
29
|
+
ignore: "¿No esperabas esto? Puedes ignorar este correo sin problema: no pasa nada hasta que aceptes.",
|
|
30
|
+
sentBy: "Enviado por {brand}",
|
|
31
|
+
help: "¿Dudas? Escribe a {support}.",
|
|
32
|
+
},
|
|
33
|
+
pt: {
|
|
34
|
+
subject: "Você foi convidado(a) para entrar em {org}",
|
|
35
|
+
preheader: "Aceite o convite para entrar em {org}.",
|
|
36
|
+
heading: "Você foi convidado(a) para entrar em {org}",
|
|
37
|
+
introWithInviter: "{inviter} convidou você para entrar em {org}.",
|
|
38
|
+
intro: "Você foi convidado(a) para entrar em {org}.",
|
|
39
|
+
roleOne: "Seu papel",
|
|
40
|
+
roleMany: "Seus papéis",
|
|
41
|
+
button: "Aceitar convite",
|
|
42
|
+
expires: "Este convite expira em {date}.",
|
|
43
|
+
fallback: "Se o botão não funcionar, copie e cole este link no navegador:",
|
|
44
|
+
ignore: "Não esperava por isso? Pode ignorar este e-mail com segurança: nada acontece até você aceitar.",
|
|
45
|
+
sentBy: "Enviado por {brand}",
|
|
46
|
+
help: "Dúvidas? Fale com {support}.",
|
|
47
|
+
},
|
|
48
|
+
fr: {
|
|
49
|
+
subject: "Vous êtes invité(e) à rejoindre {org}",
|
|
50
|
+
preheader: "Acceptez votre invitation à rejoindre {org}.",
|
|
51
|
+
heading: "Vous êtes invité(e) à rejoindre {org}",
|
|
52
|
+
introWithInviter: "{inviter} vous invite à rejoindre {org}.",
|
|
53
|
+
intro: "Vous avez été invité(e) à rejoindre {org}.",
|
|
54
|
+
roleOne: "Votre rôle",
|
|
55
|
+
roleMany: "Vos rôles",
|
|
56
|
+
button: "Accepter l'invitation",
|
|
57
|
+
expires: "Cette invitation expire le {date}.",
|
|
58
|
+
fallback: "Si le bouton ne fonctionne pas, copiez ce lien dans votre navigateur :",
|
|
59
|
+
ignore: "Vous n'attendiez rien ? Ignorez simplement cet e-mail : rien ne se passe tant que vous n'acceptez pas.",
|
|
60
|
+
sentBy: "Envoyé par {brand}",
|
|
61
|
+
help: "Une question ? Contactez {support}.",
|
|
62
|
+
},
|
|
63
|
+
de: {
|
|
64
|
+
subject: "Einladung zu {org}",
|
|
65
|
+
preheader: "Nehmen Sie die Einladung zu {org} an.",
|
|
66
|
+
heading: "Sie wurden zu {org} eingeladen",
|
|
67
|
+
introWithInviter: "{inviter} hat Sie zu {org} eingeladen.",
|
|
68
|
+
intro: "Sie wurden zu {org} eingeladen.",
|
|
69
|
+
roleOne: "Ihre Rolle",
|
|
70
|
+
roleMany: "Ihre Rollen",
|
|
71
|
+
button: "Einladung annehmen",
|
|
72
|
+
expires: "Diese Einladung läuft am {date} ab.",
|
|
73
|
+
fallback: "Falls die Schaltfläche nicht funktioniert, kopieren Sie diesen Link in Ihren Browser:",
|
|
74
|
+
ignore: "Nicht erwartet? Sie können diese E-Mail ignorieren – ohne Ihre Zusage passiert nichts.",
|
|
75
|
+
sentBy: "Gesendet von {brand}",
|
|
76
|
+
help: "Fragen? Schreiben Sie an {support}.",
|
|
77
|
+
},
|
|
78
|
+
it: {
|
|
79
|
+
subject: "Sei stato/a invitato/a a unirti a {org}",
|
|
80
|
+
preheader: "Accetta l'invito a unirti a {org}.",
|
|
81
|
+
heading: "Sei stato/a invitato/a a unirti a {org}",
|
|
82
|
+
introWithInviter: "{inviter} ti ha invitato/a a unirti a {org}.",
|
|
83
|
+
intro: "Sei stato/a invitato/a a unirti a {org}.",
|
|
84
|
+
roleOne: "Il tuo ruolo",
|
|
85
|
+
roleMany: "I tuoi ruoli",
|
|
86
|
+
button: "Accetta l'invito",
|
|
87
|
+
expires: "Questo invito scade il {date}.",
|
|
88
|
+
fallback: "Se il pulsante non funziona, copia e incolla questo link nel browser:",
|
|
89
|
+
ignore: "Non te lo aspettavi? Puoi ignorare questa e-mail: non succede nulla finché non accetti.",
|
|
90
|
+
sentBy: "Inviato da {brand}",
|
|
91
|
+
help: "Domande? Scrivi a {support}.",
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
/** `es-MX` → `es`, `PT_br` → `pt`; anything unsupported falls back to the default. */
|
|
95
|
+
export function resolveLocale(requested, fallback = DEFAULT_LOCALE) {
|
|
96
|
+
const language = requested?.trim().toLowerCase().split(/[-_]/)[0];
|
|
97
|
+
return language && language in STRINGS ? language : fallback;
|
|
98
|
+
}
|
package/dist/sender.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { type Identity, type InvitationSender } from "@uniora/core";
|
|
2
|
+
import { type SmtpConfig } from "./config.js";
|
|
3
|
+
import { type InvitationTemplate, type InvitationTemplateOptions } from "./templates.js";
|
|
4
|
+
/** The slice of a nodemailer transport the sender uses — lets tests (and other transports) plug in. */
|
|
5
|
+
export interface MailTransport {
|
|
6
|
+
sendMail(mail: {
|
|
7
|
+
from: string;
|
|
8
|
+
to: string;
|
|
9
|
+
replyTo?: string;
|
|
10
|
+
subject: string;
|
|
11
|
+
html: string;
|
|
12
|
+
text: string;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
}): Promise<unknown>;
|
|
15
|
+
verify?(): Promise<unknown>;
|
|
16
|
+
close?(): void;
|
|
17
|
+
}
|
|
18
|
+
export interface SmtpInvitationSenderOptions extends InvitationTemplateOptions {
|
|
19
|
+
/** Defaults to `loadSmtpConfig(process.env)` — i.e. the `UNIORA_SMTP_*` variables. */
|
|
20
|
+
config?: SmtpConfig;
|
|
21
|
+
/** Bring your own transport (tests, a different nodemailer setup). When set, `config` is only used for From/Reply-To. */
|
|
22
|
+
transport?: MailTransport;
|
|
23
|
+
/** Full control of subject, HTML and text. Defaults to `renderInvitationEmail`. */
|
|
24
|
+
template?: InvitationTemplate;
|
|
25
|
+
/** Turns the inviter's identity into a display name for the e-mail, e.g. a lookup in your user table. */
|
|
26
|
+
resolveInviterName?: (identity: Identity) => Promise<string | undefined> | string | undefined;
|
|
27
|
+
/** Connection pooling for the built-in transport. Default `true`. */
|
|
28
|
+
pool?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export interface SmtpInvitationSender extends InvitationSender {
|
|
31
|
+
/** Opens a connection and authenticates, without sending anything. Use at startup or in a health check. */
|
|
32
|
+
verify(): Promise<void>;
|
|
33
|
+
/** Closes pooled connections. */
|
|
34
|
+
close(): void;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Failures a retry can't fix: bad credentials, a rejected sender/recipient or
|
|
38
|
+
* any 5xx reply. Network trouble, timeouts and 4xx (greylisting, throttling,
|
|
39
|
+
* mailbox busy) are transient and worth another attempt.
|
|
40
|
+
*/
|
|
41
|
+
export declare function isPermanentSmtpFailure(error: unknown): boolean;
|
|
42
|
+
/**
|
|
43
|
+
* The SMTP implementation of `InvitationSender`. Plug it into
|
|
44
|
+
* `createInvitationService({ sender })`; retries, timeouts and delivery
|
|
45
|
+
* bookkeeping are the service's job, so this only has to render, send and
|
|
46
|
+
* classify failures correctly.
|
|
47
|
+
*/
|
|
48
|
+
export declare function createSmtpInvitationSender(options?: SmtpInvitationSenderOptions): SmtpInvitationSender;
|
|
49
|
+
/** `createSmtpInvitationSender` configured entirely from `UNIORA_SMTP_*`; throws `SmtpConfigError` listing everything missing. */
|
|
50
|
+
export declare function createSmtpInvitationSenderFromEnv(env?: Readonly<Record<string, string | undefined>>, options?: Omit<SmtpInvitationSenderOptions, "config">): SmtpInvitationSender;
|
package/dist/sender.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createTransport } from "nodemailer";
|
|
2
|
+
import { InvitationDeliveryError } from "@uniora/core";
|
|
3
|
+
import { loadSmtpConfig } from "./config.js";
|
|
4
|
+
import { renderInvitationEmail, } from "./templates.js";
|
|
5
|
+
/**
|
|
6
|
+
* Failures a retry can't fix: bad credentials, a rejected sender/recipient or
|
|
7
|
+
* any 5xx reply. Network trouble, timeouts and 4xx (greylisting, throttling,
|
|
8
|
+
* mailbox busy) are transient and worth another attempt.
|
|
9
|
+
*/
|
|
10
|
+
export function isPermanentSmtpFailure(error) {
|
|
11
|
+
const { code, responseCode } = (error ?? {});
|
|
12
|
+
if (code === "EAUTH")
|
|
13
|
+
return true;
|
|
14
|
+
if (typeof responseCode === "number")
|
|
15
|
+
return responseCode >= 500 && responseCode < 600;
|
|
16
|
+
return code === "EENVELOPE" || code === "EMESSAGE";
|
|
17
|
+
}
|
|
18
|
+
function toDeliveryError(error) {
|
|
19
|
+
const { code, responseCode, message } = (error ?? {});
|
|
20
|
+
const detail = [typeof code === "string" ? code : undefined, typeof responseCode === "number" ? String(responseCode) : undefined]
|
|
21
|
+
.filter(Boolean)
|
|
22
|
+
.join(" ");
|
|
23
|
+
const text = `SMTP delivery failed${detail ? ` (${detail})` : ""}: ${typeof message === "string" ? message : String(error)}`;
|
|
24
|
+
return new InvitationDeliveryError(text, isPermanentSmtpFailure(error), { cause: error });
|
|
25
|
+
}
|
|
26
|
+
function buildTransport(config, pool) {
|
|
27
|
+
const smtp = {
|
|
28
|
+
host: config.host,
|
|
29
|
+
port: config.port,
|
|
30
|
+
secure: config.secure,
|
|
31
|
+
requireTLS: !config.secure && config.requireTLS,
|
|
32
|
+
auth: config.auth,
|
|
33
|
+
tls: { rejectUnauthorized: config.rejectUnauthorized, minVersion: "TLSv1.2" },
|
|
34
|
+
// Below the delivery engine's per-attempt budget, so a dead server fails
|
|
35
|
+
// here with a real error instead of by an abandoned promise.
|
|
36
|
+
connectionTimeout: 10_000,
|
|
37
|
+
greetingTimeout: 10_000,
|
|
38
|
+
socketTimeout: 20_000,
|
|
39
|
+
};
|
|
40
|
+
const transporter = pool
|
|
41
|
+
? createTransport({ ...smtp, pool: true, maxConnections: 3 })
|
|
42
|
+
: createTransport(smtp);
|
|
43
|
+
return transporter;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The SMTP implementation of `InvitationSender`. Plug it into
|
|
47
|
+
* `createInvitationService({ sender })`; retries, timeouts and delivery
|
|
48
|
+
* bookkeeping are the service's job, so this only has to render, send and
|
|
49
|
+
* classify failures correctly.
|
|
50
|
+
*/
|
|
51
|
+
export function createSmtpInvitationSender(options = {}) {
|
|
52
|
+
const config = options.config ?? loadSmtpConfig();
|
|
53
|
+
const template = options.template ?? renderInvitationEmail;
|
|
54
|
+
const transport = options.transport ?? buildTransport(config, options.pool ?? true);
|
|
55
|
+
return {
|
|
56
|
+
async send(message, context) {
|
|
57
|
+
let inviterName;
|
|
58
|
+
try {
|
|
59
|
+
inviterName = await options.resolveInviterName?.(message.invitedBy);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
inviterName = undefined; // a failed name lookup must never block the invitation
|
|
63
|
+
}
|
|
64
|
+
const rendered = await template(message, {
|
|
65
|
+
brandName: options.brandName,
|
|
66
|
+
brandColor: options.brandColor,
|
|
67
|
+
logoUrl: options.logoUrl,
|
|
68
|
+
supportEmail: options.supportEmail,
|
|
69
|
+
defaultLocale: options.defaultLocale,
|
|
70
|
+
inviterName,
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
await transport.sendMail({
|
|
74
|
+
from: config.from,
|
|
75
|
+
to: message.to,
|
|
76
|
+
replyTo: config.replyTo,
|
|
77
|
+
subject: rendered.subject,
|
|
78
|
+
html: rendered.html,
|
|
79
|
+
text: rendered.text,
|
|
80
|
+
headers: {
|
|
81
|
+
// Machine-generated transactional mail: keeps auto-responders quiet.
|
|
82
|
+
"Auto-Submitted": "auto-generated",
|
|
83
|
+
"X-Auto-Response-Suppress": "All",
|
|
84
|
+
// Correlates provider logs with the invitation without exposing the token.
|
|
85
|
+
"X-Entity-Ref-ID": `${message.invitationId}.${context.attempt}`,
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
throw toDeliveryError(error);
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
async verify() {
|
|
94
|
+
if (!transport.verify)
|
|
95
|
+
return;
|
|
96
|
+
try {
|
|
97
|
+
await transport.verify();
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
throw toDeliveryError(error);
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
close() {
|
|
104
|
+
transport.close?.();
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** `createSmtpInvitationSender` configured entirely from `UNIORA_SMTP_*`; throws `SmtpConfigError` listing everything missing. */
|
|
109
|
+
export function createSmtpInvitationSenderFromEnv(env = process.env, options = {}) {
|
|
110
|
+
return createSmtpInvitationSender({ ...options, config: loadSmtpConfig(env) });
|
|
111
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { InvitationMessage } from "@uniora/core";
|
|
2
|
+
import { type EmailLocale } from "./locales.js";
|
|
3
|
+
export interface RenderedEmail {
|
|
4
|
+
subject: string;
|
|
5
|
+
html: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}
|
|
8
|
+
export interface InvitationTemplateOptions {
|
|
9
|
+
/** Shown in the header and footer. Default: the organization's name is used as the header and "UNIORA" in the footer. */
|
|
10
|
+
brandName?: string;
|
|
11
|
+
/** `#rrggbb` accent for the button and header rule. Default indigo. */
|
|
12
|
+
brandColor?: string;
|
|
13
|
+
/** Absolute https URL of a logo (max ~180×48 px works best). Omitted → text header. */
|
|
14
|
+
logoUrl?: string;
|
|
15
|
+
/** Shown in the footer help line. */
|
|
16
|
+
supportEmail?: string;
|
|
17
|
+
/** Used when `message.locale` is missing or unsupported. Default `en`. */
|
|
18
|
+
defaultLocale?: EmailLocale;
|
|
19
|
+
}
|
|
20
|
+
export interface InvitationTemplateContext extends InvitationTemplateOptions {
|
|
21
|
+
/** Display name of the person who invited, when you can resolve one. */
|
|
22
|
+
inviterName?: string;
|
|
23
|
+
}
|
|
24
|
+
/** Replace this to take full control of the e-mail (any markup, any copy). */
|
|
25
|
+
export type InvitationTemplate = (message: InvitationMessage, context: InvitationTemplateContext) => RenderedEmail | Promise<RenderedEmail>;
|
|
26
|
+
export declare function escapeHtml(value: string): string;
|
|
27
|
+
/** One line, bounded: names go into the Subject header and must never carry line breaks. */
|
|
28
|
+
export declare function singleLine(value: string, max?: number): string;
|
|
29
|
+
/**
|
|
30
|
+
* The default invitation e-mail: a responsive, table-based layout that holds up
|
|
31
|
+
* in Outlook, Gmail and Apple Mail (inline styles, a bulletproof button with a
|
|
32
|
+
* VML fallback, a hidden preheader, dark-mode support) plus a plain-text
|
|
33
|
+
* alternative. Everything interpolated is escaped, the link must be http(s)
|
|
34
|
+
* and the Subject is forced onto one line.
|
|
35
|
+
*/
|
|
36
|
+
export declare function renderInvitationEmail(message: InvitationMessage, context?: InvitationTemplateContext): RenderedEmail;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { DEFAULT_LOCALE, resolveLocale, STRINGS } from "./locales.js";
|
|
2
|
+
const DEFAULT_COLOR = "#4f46e5";
|
|
3
|
+
export function escapeHtml(value) {
|
|
4
|
+
return value
|
|
5
|
+
.replace(/&/g, "&")
|
|
6
|
+
.replace(/</g, "<")
|
|
7
|
+
.replace(/>/g, ">")
|
|
8
|
+
.replace(/"/g, """)
|
|
9
|
+
.replace(/'/g, "'");
|
|
10
|
+
}
|
|
11
|
+
/** One line, bounded: names go into the Subject header and must never carry line breaks. */
|
|
12
|
+
export function singleLine(value, max = 80) {
|
|
13
|
+
const flat = value.replace(/[\u0000-\u001f\u007f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
14
|
+
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
|
15
|
+
}
|
|
16
|
+
function fill(template, values) {
|
|
17
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => values[key] ?? match);
|
|
18
|
+
}
|
|
19
|
+
function safeColor(color) {
|
|
20
|
+
return color && /^#[0-9a-f]{6}$/i.test(color) ? color.toLowerCase() : DEFAULT_COLOR;
|
|
21
|
+
}
|
|
22
|
+
/** Black or white, whichever reads better on `hex` (WCAG relative luminance). */
|
|
23
|
+
function readableOn(hex) {
|
|
24
|
+
const [r, g, b] = [1, 3, 5].map((index) => {
|
|
25
|
+
const channel = parseInt(hex.slice(index, index + 2), 16) / 255;
|
|
26
|
+
return channel <= 0.03928 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
27
|
+
});
|
|
28
|
+
const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
29
|
+
return luminance > 0.4 ? "#111827" : "#ffffff";
|
|
30
|
+
}
|
|
31
|
+
function assertHttpUrl(value, what, httpsOnly = false) {
|
|
32
|
+
let url;
|
|
33
|
+
try {
|
|
34
|
+
url = new URL(value);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
throw new TypeError(`${what} must be an absolute URL.`);
|
|
38
|
+
}
|
|
39
|
+
const allowed = httpsOnly ? ["https:"] : ["https:", "http:"];
|
|
40
|
+
if (!allowed.includes(url.protocol))
|
|
41
|
+
throw new TypeError(`${what} must use ${httpsOnly ? "https" : "http(s)"}.`);
|
|
42
|
+
return url.toString();
|
|
43
|
+
}
|
|
44
|
+
function formatDate(date, locale) {
|
|
45
|
+
try {
|
|
46
|
+
return `${new Intl.DateTimeFormat(locale, { dateStyle: "long", timeStyle: "short", timeZone: "UTC" }).format(date)} UTC`;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return date.toISOString();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The default invitation e-mail: a responsive, table-based layout that holds up
|
|
54
|
+
* in Outlook, Gmail and Apple Mail (inline styles, a bulletproof button with a
|
|
55
|
+
* VML fallback, a hidden preheader, dark-mode support) plus a plain-text
|
|
56
|
+
* alternative. Everything interpolated is escaped, the link must be http(s)
|
|
57
|
+
* and the Subject is forced onto one line.
|
|
58
|
+
*/
|
|
59
|
+
export function renderInvitationEmail(message, context = {}) {
|
|
60
|
+
const locale = resolveLocale(message.locale, context.defaultLocale ?? DEFAULT_LOCALE);
|
|
61
|
+
const t = STRINGS[locale];
|
|
62
|
+
const acceptUrl = assertHttpUrl(message.acceptUrl, "acceptUrl");
|
|
63
|
+
const logoUrl = context.logoUrl ? assertHttpUrl(context.logoUrl, "logoUrl", true) : undefined;
|
|
64
|
+
const color = safeColor(context.brandColor);
|
|
65
|
+
const onColor = readableOn(color);
|
|
66
|
+
const org = singleLine(message.organization.name, 120);
|
|
67
|
+
const brand = singleLine(context.brandName ?? "UNIORA", 60);
|
|
68
|
+
const inviter = context.inviterName ? singleLine(context.inviterName, 80) : undefined;
|
|
69
|
+
const roles = message.roleNames.map((role) => singleLine(role, 60)).filter(Boolean);
|
|
70
|
+
const when = formatDate(message.expiresAt, locale);
|
|
71
|
+
const support = context.supportEmail ? singleLine(context.supportEmail, 120) : undefined;
|
|
72
|
+
const subject = singleLine(fill(t.subject, { org }), 150);
|
|
73
|
+
const introText = inviter ? fill(t.introWithInviter, { inviter, org }) : fill(t.intro, { org });
|
|
74
|
+
const roleLabel = roles.length > 1 ? t.roleMany : t.roleOne;
|
|
75
|
+
const heading = fill(t.heading, { org });
|
|
76
|
+
const preheader = fill(t.preheader, { org });
|
|
77
|
+
const expires = fill(t.expires, { date: when });
|
|
78
|
+
const sentBy = fill(t.sentBy, { brand });
|
|
79
|
+
const help = support ? fill(t.help, { support }) : undefined;
|
|
80
|
+
// Each interpolated value is escaped on its own and then emphasised, so a
|
|
81
|
+
// hostile name can't break out of the markup (and `$` in a name is inert).
|
|
82
|
+
const e = escapeHtml;
|
|
83
|
+
const introHtml = (inviter ? t.introWithInviter : t.intro)
|
|
84
|
+
.split(/(\{org\}|\{inviter\})/)
|
|
85
|
+
.map((part) => part === "{org}" ? `<strong>${e(org)}</strong>` : part === "{inviter}" ? `<strong>${e(inviter ?? "")}</strong>` : e(part))
|
|
86
|
+
.join("");
|
|
87
|
+
const url = e(acceptUrl);
|
|
88
|
+
const header = logoUrl
|
|
89
|
+
? `<img src="${e(logoUrl)}" alt="${e(brand)}" height="32" style="display:block;border:0;height:32px;width:auto;max-width:180px;">`
|
|
90
|
+
: `<span style="font-size:18px;font-weight:700;letter-spacing:.2px;color:#111827;" class="ink">${e(brand)}</span>`;
|
|
91
|
+
const roleBlock = roles.length
|
|
92
|
+
? `<tr><td style="padding:0 40px 24px 40px;">
|
|
93
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="panel" style="background:#f3f4f6;border-radius:10px;">
|
|
94
|
+
<tr><td style="padding:14px 18px;font-size:13px;line-height:20px;color:#6b7280;" class="muted">${e(roleLabel)}</td></tr>
|
|
95
|
+
<tr><td style="padding:0 18px 14px 18px;font-size:15px;line-height:22px;font-weight:600;color:#111827;" class="ink">${roles.map(e).join(" · ")}</td></tr>
|
|
96
|
+
</table>
|
|
97
|
+
</td></tr>`
|
|
98
|
+
: "";
|
|
99
|
+
const html = `<!doctype html>
|
|
100
|
+
<html lang="${locale}" xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
|
101
|
+
<head>
|
|
102
|
+
<meta charset="utf-8">
|
|
103
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
104
|
+
<meta name="color-scheme" content="light dark">
|
|
105
|
+
<meta name="supported-color-schemes" content="light dark">
|
|
106
|
+
<title>${e(subject)}</title>
|
|
107
|
+
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
|
|
108
|
+
<style>
|
|
109
|
+
body,table,td,a{-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;}
|
|
110
|
+
table,td{mso-table-lspace:0;mso-table-rspace:0;}
|
|
111
|
+
img{-ms-interpolation-mode:bicubic;}
|
|
112
|
+
@media (max-width:620px){ .container{width:100%!important;} .px{padding-left:24px!important;padding-right:24px!important;} }
|
|
113
|
+
@media (prefers-color-scheme:dark){
|
|
114
|
+
.bg{background:#0b0f19!important;} .card{background:#111827!important;border-color:#1f2937!important;}
|
|
115
|
+
.ink{color:#f9fafb!important;} .body{color:#d1d5db!important;} .muted{color:#9ca3af!important;}
|
|
116
|
+
.panel{background:#1f2937!important;} .rule{border-top-color:#1f2937!important;} .link{color:#a5b4fc!important;}
|
|
117
|
+
}
|
|
118
|
+
</style>
|
|
119
|
+
</head>
|
|
120
|
+
<body class="bg" style="margin:0;padding:0;background:#f4f5f7;">
|
|
121
|
+
<div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent;mso-hide:all;">${e(preheader)}͏‌ ͏‌ ͏‌ </div>
|
|
122
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%" class="bg" style="background:#f4f5f7;">
|
|
123
|
+
<tr><td align="center" style="padding:32px 16px;">
|
|
124
|
+
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="560" class="container card" style="width:560px;max-width:560px;background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;">
|
|
125
|
+
<tr><td class="px" style="padding:28px 40px 20px 40px;border-bottom:3px solid ${color};border-radius:14px 14px 0 0;">${header}</td></tr>
|
|
126
|
+
<tr><td class="px ink" style="padding:32px 40px 8px 40px;font-size:24px;line-height:32px;font-weight:700;color:#111827;">${e(heading)}</td></tr>
|
|
127
|
+
<tr><td class="px body" style="padding:8px 40px 24px 40px;font-size:16px;line-height:26px;color:#374151;">${introHtml}</td></tr>
|
|
128
|
+
${roleBlock}
|
|
129
|
+
<tr><td class="px" align="left" style="padding:0 40px 24px 40px;">
|
|
130
|
+
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" href="${url}" style="height:48px;v-text-anchor:middle;width:240px;" arcsize="20%" stroke="f" fillcolor="${color}"><w:anchorlock xmlns:w="urn:schemas-microsoft-com:office:word"/><center style="color:${onColor};font-family:Arial,sans-serif;font-size:16px;font-weight:bold;">${e(t.button)}</center></v:roundrect><![endif]-->
|
|
131
|
+
<!--[if !mso]><!-- --><a href="${url}" target="_blank" rel="noopener" style="display:inline-block;background:${color};color:${onColor};font-size:16px;line-height:48px;font-weight:600;text-decoration:none;padding:0 32px;border-radius:10px;mso-hide:all;">${e(t.button)}</a><!--<![endif]-->
|
|
132
|
+
</td></tr>
|
|
133
|
+
<tr><td class="px muted" style="padding:0 40px 8px 40px;font-size:14px;line-height:22px;color:#6b7280;">${e(expires)}</td></tr>
|
|
134
|
+
<tr><td class="px muted" style="padding:16px 40px 4px 40px;font-size:13px;line-height:20px;color:#6b7280;">${e(t.fallback)}</td></tr>
|
|
135
|
+
<tr><td class="px" style="padding:0 40px 24px 40px;font-size:13px;line-height:20px;word-break:break-all;"><a class="link" href="${url}" style="color:${color};text-decoration:underline;">${url}</a></td></tr>
|
|
136
|
+
<tr><td class="px" style="padding:0 40px;"><div class="rule" style="border-top:1px solid #e5e7eb;font-size:0;line-height:0;"> </div></td></tr>
|
|
137
|
+
<tr><td class="px muted" style="padding:20px 40px 6px 40px;font-size:13px;line-height:20px;color:#6b7280;">${e(t.ignore)}</td></tr>
|
|
138
|
+
<tr><td class="px muted" style="padding:6px 40px ${help ? "6px" : "28px"} 40px;font-size:12px;line-height:18px;color:#9ca3af;">${e(sentBy)}</td></tr>
|
|
139
|
+
${help ? `<tr><td class="px muted" style="padding:0 40px 28px 40px;font-size:12px;line-height:18px;color:#9ca3af;">${e(help)}</td></tr>` : ""}
|
|
140
|
+
</table>
|
|
141
|
+
</td></tr>
|
|
142
|
+
</table>
|
|
143
|
+
</body>
|
|
144
|
+
</html>`;
|
|
145
|
+
const text = [
|
|
146
|
+
heading,
|
|
147
|
+
"",
|
|
148
|
+
introText,
|
|
149
|
+
roles.length ? `${roleLabel}: ${roles.join(", ")}` : undefined,
|
|
150
|
+
"",
|
|
151
|
+
`${t.button}: ${acceptUrl}`,
|
|
152
|
+
"",
|
|
153
|
+
expires,
|
|
154
|
+
"",
|
|
155
|
+
t.ignore,
|
|
156
|
+
"",
|
|
157
|
+
"--",
|
|
158
|
+
sentBy,
|
|
159
|
+
help,
|
|
160
|
+
]
|
|
161
|
+
.filter((line) => line !== undefined)
|
|
162
|
+
.join("\n");
|
|
163
|
+
return { subject, html, text };
|
|
164
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniora/mailer-smtp",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "SMTP delivery for UNIORA invitations: configured from .env, with professional multilingual e-mail templates",
|
|
5
|
+
"license": "PolyForm-Shield-1.0.0",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"uniora",
|
|
8
|
+
"smtp",
|
|
9
|
+
"email",
|
|
10
|
+
"invitations",
|
|
11
|
+
"nodemailer"
|
|
12
|
+
],
|
|
13
|
+
"homepage": "https://github.com/CodexSploitx/uniora#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/CodexSploitx/uniora/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/CodexSploitx/uniora.git",
|
|
20
|
+
"directory": "packages/mailer-smtp"
|
|
21
|
+
},
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"dist"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=22"
|
|
39
|
+
},
|
|
40
|
+
"dependencies": {
|
|
41
|
+
"nodemailer": "^10.0.6",
|
|
42
|
+
"@uniora/core": "^0.3.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@types/node": "^22.10.2",
|
|
46
|
+
"@types/smtp-server": "^3.5.13",
|
|
47
|
+
"smtp-server": "^3.19.17",
|
|
48
|
+
"typescript": "^5.6.3",
|
|
49
|
+
"vitest": "^2.1.4"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "tsc -p tsconfig.json",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"typecheck": "tsc --noEmit"
|
|
55
|
+
}
|
|
56
|
+
}
|