@veap/mail 0.0.0-stage → 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Damian Miller
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,128 @@
1
- # Temporary Holding Version
1
+ [![CI](https://github.com/veapjs/veap-mail/actions/workflows/CI.yml/badge.svg)](https://github.com/veapjs/veap-mail/actions/workflows/CI.yml)
2
+ [![Release](https://github.com/veapjs/veap-mail/actions/workflows/RELEASE.yml/badge.svg)](https://github.com/veapjs/veap-mail/actions/workflows/RELEASE.yml)
3
+ [![Version](https://img.shields.io/npm/v/@veap/mail?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/@veap/mail)
2
4
 
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.
5
+ # @veap/mail
6
+
7
+ Modular email and notification transport abstraction with support for
8
+ Nodemailer, SMTP, Console logging, and custom mail drivers for Veap applications.
9
+
10
+ ## Overview
11
+
12
+ `@veap/mail` provides a unified messaging interface decoupling application
13
+ logic from email delivery protocols. It supports zero-configuration development
14
+ environments with automatic console logging, robust SMTP / Nodemailer delivery
15
+ in production, and a custom driver port (`CUSTOM_MAILER`) for external email
16
+ services such as AWS SES, Postmark, or Resend.
17
+
18
+ ## Features
19
+
20
+ - **Multiple mail transports**: Built-in support for Nodemailer (SMTP, Google
21
+ App Passwords) and zero-config `ConsoleMailService` for local development.
22
+ - **Unified `sendMail` facade**: Simple, consistent API for sending plain text,
23
+ HTML, and rich multipart emails.
24
+ - **Authentication notification helpers**: Out-of-the-box functions for
25
+ `sendResetPassword` and `sendVerifyEmail` with customizable templates.
26
+ - **IoC-driven lifecycle**: `MailServiceProvider` handles automatic driver
27
+ selection and credentials parsing during application boot.
28
+ - **Custom mailer support**: Extendable driver architecture via the
29
+ `CUSTOM_MAILER` token.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ # Using bun
35
+ bun add @veap/mail @veap/kernel
36
+
37
+ # Using pnpm
38
+ pnpm add @veap/mail @veap/kernel
39
+
40
+ # Using npm
41
+ npm install @veap/mail @veap/kernel
42
+ ```
43
+
44
+ ## Configuration
45
+
46
+ `@veap/mail` detects transports automatically from environment variables:
47
+
48
+ | Environment Variable | Description |
49
+ | ---------------------------------------- | ---------------------------------------------------- |
50
+ | `MAIL_TRANSPORT` | Delivery driver (`smtp`, `console`, or `custom`) |
51
+ | `MAIL_SERVICE` | Well-known Nodemailer service (for example, `gmail`) |
52
+ | `MAIL_USERNAME` / `MAIL_PASSWORD` | Standard SMTP credentials |
53
+ | `GOOGLE_SMTP_APP_USERNAME` / `_PASSWORD` | Alternative Google App password credentials |
54
+ | `MAIL_FROM_ADDRESS` | Default sender email address |
55
+
56
+ When no credentials or `MAIL_TRANSPORT` are set, `@veap/mail` defaults safely
57
+ to `ConsoleMailService` so local development and tests run without network errors.
58
+
59
+ ## Usage
60
+
61
+ ### Register in Application Builder
62
+
63
+ Include `MailServiceProvider` in your application composition root:
64
+
65
+ ```ts
66
+ import { Application } from "@veap/kernel";
67
+ import { MailServiceProvider } from "@veap/mail";
68
+
69
+ export const app = Application.configure().use(MailServiceProvider).create();
70
+ ```
71
+
72
+ ### Sending Emails
73
+
74
+ Send transactional messages from services or server actions:
75
+
76
+ ```ts
77
+ import { sendMail } from "@veap/mail";
78
+
79
+ await sendMail({
80
+ to: "user@example.com",
81
+ subject: "Welcome to Our Platform",
82
+ text: "Thank you for creating an account with us!",
83
+ html: "<h1>Welcome!</h1><p>Thank you for creating an account with us!</p>",
84
+ });
85
+ ```
86
+
87
+ ### Registering a Custom Mailer
88
+
89
+ Implement the `IMailer` interface and bind it under `CUSTOM_MAILER`:
90
+
91
+ ```ts
92
+ import { ServiceProvider } from "@veap/kernel";
93
+ import { CUSTOM_MAILER, type IMailer, type MailMessage } from "@veap/mail";
94
+
95
+ export class ResendMailService implements IMailer {
96
+ async send(message: MailMessage): Promise<void> {
97
+ // Send via third-party SDK (e.g., resend.emails.send)
98
+ }
99
+ }
100
+
101
+ export class CustomMailServiceProvider extends ServiceProvider {
102
+ register(): void {
103
+ this.container.register({
104
+ token: CUSTOM_MAILER,
105
+ useClass: ResendMailService,
106
+ singleton: true,
107
+ });
108
+ }
109
+ }
110
+ ```
111
+
112
+ ## Subpath Exports
113
+
114
+ | Export Path | Content |
115
+ | ------------ | ---------------------------------------------------------------------------------------- |
116
+ | `@veap/mail` | Mail facades (`sendMail`), providers, interfaces (`IMailer`, `MailMessage`), and context |
117
+
118
+ ## Documentation
119
+
120
+ For full guides and transport configuration options, visit:
121
+
122
+ - **Official Website**: [https://veap.pl](https://veap.pl)
123
+ - **Documentation**: [https://veap.pl/docs](https://veap.pl/docs)
124
+ - **GitHub Repository**: [https://github.com/veapjs/veap-mail](https://github.com/veapjs/veap-mail)
125
+
126
+ ## License
127
+
128
+ MIT
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Mail configuration options supported in veap.config.ts.
3
+ */
4
+ export interface MailConfig {
5
+ /**
6
+ * Transport driver: "smtp" (default), "console", or "custom".
7
+ */
8
+ transport?: "smtp" | "console" | "custom" | string;
9
+ /**
10
+ * Nodemailer well-known service (e.g. "gmail", "sendgrid").
11
+ */
12
+ service?: string;
13
+ /**
14
+ * SMTP server hostname.
15
+ */
16
+ host?: string;
17
+ /**
18
+ * SMTP server port (e.g. 587, 465, 25, 1025).
19
+ */
20
+ port?: number | string;
21
+ /**
22
+ * Use TLS/SSL directly (default: true for port 465, false otherwise).
23
+ */
24
+ secure?: boolean;
25
+ /**
26
+ * Ignore TLS certificate issues (useful for local dev/test servers).
27
+ */
28
+ ignoreTLS?: boolean;
29
+ /**
30
+ * SMTP authentication username.
31
+ */
32
+ username?: string;
33
+ /**
34
+ * SMTP authentication password.
35
+ */
36
+ password?: string;
37
+ /**
38
+ * Default sender address (e.g. "noreply@example.com" or "Acme <noreply@example.com>").
39
+ */
40
+ from?: string;
41
+ /**
42
+ * Default sender name (e.g. "Acme").
43
+ */
44
+ fromName?: string;
45
+ }
46
+ declare module "@veap/kernel" {
47
+ interface ApplicationBuilder {
48
+ /**
49
+ * Registers the communication/mail service provider into the application.
50
+ */
51
+ withMail(): this;
52
+ /**
53
+ * Alias for `withMail()`.
54
+ */
55
+ withCommunication(): this;
56
+ }
57
+ interface VeapConfig {
58
+ /**
59
+ * Communication and mail transport configuration.
60
+ */
61
+ mail?: MailConfig;
62
+ }
63
+ }
@@ -0,0 +1,8 @@
1
+ import { ApplicationBuilder } from "@veap/kernel";
2
+ import { CommunicationServiceProvider } from "./provider.js";
3
+ ApplicationBuilder.prototype.withMail = function () {
4
+ return this.use(CommunicationServiceProvider);
5
+ };
6
+ ApplicationBuilder.prototype.withCommunication = function () {
7
+ return this.use(CommunicationServiceProvider);
8
+ };
@@ -0,0 +1,22 @@
1
+ import type { IMailer } from "./mailer.js";
2
+ /**
3
+ * Typed boundary between the communication use cases and their consumers
4
+ * (facades, application services). The same pattern as
5
+ * `application/auth/context.ts` and `application/plugins/context.ts`.
6
+ *
7
+ * The composition root (`CommunicationServiceProvider`) selects the mail
8
+ * transport once at boot and binds it here.
9
+ */
10
+ export interface CommunicationContext {
11
+ mailer: IMailer;
12
+ }
13
+ /**
14
+ * Binds the resolved communication services. Called once by the composition root.
15
+ */
16
+ export declare function bindCommunicationContext(context: CommunicationContext): void;
17
+ /**
18
+ * Returns the bound communication services.
19
+ *
20
+ * @throws If the communication service provider has not booted yet.
21
+ */
22
+ export declare function communicationContext(): CommunicationContext;
@@ -0,0 +1,20 @@
1
+ import { AppError } from "@veap/kernel";
2
+ const globalForCommunicationContext = globalThis;
3
+ /**
4
+ * Binds the resolved communication services. Called once by the composition root.
5
+ */
6
+ export function bindCommunicationContext(context) {
7
+ globalForCommunicationContext.__VEAP_COMMUNICATION_CONTEXT__ = context;
8
+ }
9
+ /**
10
+ * Returns the bound communication services.
11
+ *
12
+ * @throws If the communication service provider has not booted yet.
13
+ */
14
+ export function communicationContext() {
15
+ const context = globalForCommunicationContext.__VEAP_COMMUNICATION_CONTEXT__;
16
+ if (!context) {
17
+ throw AppError.Internal("[Communication] Context is not bound. CommunicationServiceProvider must boot before the communication facades are used.");
18
+ }
19
+ return context;
20
+ }
@@ -0,0 +1,11 @@
1
+ export * from "./composition.js";
2
+ export * from "./context.js";
3
+ export * from "./mail.js";
4
+ export type { MailMessage as SendMailOptions } from "./mail-message.js";
5
+ export * from "./mail-message.js";
6
+ export * from "./mailer.js";
7
+ export * from "./provider.js";
8
+ export * from "./providers/console.provider.js";
9
+ export * from "./providers/nodemailer.provider.js";
10
+ export { NodemailerMailService as MailService } from "./providers/nodemailer.provider.js";
11
+ export * from "./translator.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export * from "./composition.js";
2
+ export * from "./context.js";
3
+ export * from "./mail.js";
4
+ export * from "./mail-message.js";
5
+ export * from "./mailer.js";
6
+ export * from "./provider.js";
7
+ export * from "./providers/console.provider.js";
8
+ export * from "./providers/nodemailer.provider.js";
9
+ export { NodemailerMailService as MailService } from "./providers/nodemailer.provider.js";
10
+ export * from "./translator.js";
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Transport-agnostic mail DTOs.
3
+ *
4
+ * These are the *only* types a mail transport adapter is allowed to require:
5
+ * the port (`IMailer`) speaks this vocabulary, never a concrete provider's
6
+ * (Nodemailer, SES, Postmark, Resend…). Adapters map `MailMessage` onto their
7
+ * own SDK types - that mapping is precisely what makes an adapter an adapter.
8
+ */
9
+ /** A named email address, e.g. `{ name: "Veap", address: "noreply@app.dev" }`. */
10
+ export interface MailAddress {
11
+ name?: string;
12
+ address: string;
13
+ }
14
+ /** A file attached to the message. */
15
+ export interface MailAttachment {
16
+ filename?: string;
17
+ /** Inline content (string or Buffer). Mutually exclusive with `path`. */
18
+ content?: string | Buffer;
19
+ /** File path to read the attachment from. Mutually exclusive with `content`. */
20
+ path?: string;
21
+ contentType?: string;
22
+ /** Content-ID for inline images referenced from HTML (`cid:`). */
23
+ cid?: string;
24
+ encoding?: string;
25
+ }
26
+ /** An address or a plain string address. */
27
+ export type MailRecipient = string | MailAddress;
28
+ /**
29
+ * The framework's mail message.
30
+ *
31
+ * Intentionally minimal and framework-neutral: a transport adapter maps it
32
+ * onto its provider's native shape. Anything provider-specific (SES
33
+ * `SourceArn`, Postmark `Metadata`, Resend tags…) belongs to adapter
34
+ * configuration, not to this contract.
35
+ */
36
+ export interface MailMessage {
37
+ /** Primary recipient(s). */
38
+ to: MailRecipient | MailRecipient[];
39
+ subject: string;
40
+ /** Plain-text body. */
41
+ text?: string;
42
+ /** HTML body. At least one of `text` / `html` should be present. */
43
+ html?: string;
44
+ /** Overrides the transport's default sender (e.g. `MAIL_FROM_ADDRESS`). */
45
+ from?: MailRecipient;
46
+ cc?: MailRecipient | MailRecipient[];
47
+ bcc?: MailRecipient | MailRecipient[];
48
+ replyTo?: MailRecipient;
49
+ attachments?: MailAttachment[];
50
+ priority?: "high" | "normal" | "low";
51
+ /** Custom message headers. */
52
+ headers?: Record<string, string>;
53
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Transport-agnostic mail DTOs.
3
+ *
4
+ * These are the *only* types a mail transport adapter is allowed to require:
5
+ * the port (`IMailer`) speaks this vocabulary, never a concrete provider's
6
+ * (Nodemailer, SES, Postmark, Resend…). Adapters map `MailMessage` onto their
7
+ * own SDK types - that mapping is precisely what makes an adapter an adapter.
8
+ */
9
+ export {};
package/dist/mail.d.ts ADDED
@@ -0,0 +1,19 @@
1
+ import type { MailMessage } from "./mail-message.js";
2
+ /**
3
+ * Mailables - the domain vocabulary of email.
4
+ *
5
+ * Transports deliver; mailables decide *what* is sent. Each helper builds a
6
+ * `MailMessage` (localized via the injected translator) and hands it to the
7
+ * bound `IMailer` port. This is the Laravel split: `Mailable` defines the
8
+ * content, `Transport` only delivers it - a transport never knows whether it
9
+ * is carrying a verification code or a newsletter.
10
+ *
11
+ * Application services (auth flows) and plugins call these helpers; swapping
12
+ * the transport never touches them, and adding a new mail kind never touches
13
+ * a transport.
14
+ */
15
+ export declare function sendMail(message: MailMessage): Promise<void>;
16
+ export declare function sendVerifyEmail(email: string, code: string): Promise<void>;
17
+ export declare function sendResetPassword(email: string, code: string): Promise<void>;
18
+ export declare function sendRecoveryCode(email: string, recoveryCode: string): Promise<void>;
19
+ export declare function send2FACode(email: string, code: string): Promise<void>;
package/dist/mail.js ADDED
@@ -0,0 +1,52 @@
1
+ import { communicationContext } from "./context.js";
2
+ import { getMailTranslator } from "./translator.js";
3
+ /**
4
+ * Mailables - the domain vocabulary of email.
5
+ *
6
+ * Transports deliver; mailables decide *what* is sent. Each helper builds a
7
+ * `MailMessage` (localized via the injected translator) and hands it to the
8
+ * bound `IMailer` port. This is the Laravel split: `Mailable` defines the
9
+ * content, `Transport` only delivers it - a transport never knows whether it
10
+ * is carrying a verification code or a newsletter.
11
+ *
12
+ * Application services (auth flows) and plugins call these helpers; swapping
13
+ * the transport never touches them, and adding a new mail kind never touches
14
+ * a transport.
15
+ */
16
+ export async function sendMail(message) {
17
+ return communicationContext().mailer.sendMail(message);
18
+ }
19
+ export async function sendVerifyEmail(email, code) {
20
+ const t = await getMailTranslator();
21
+ return sendMail({
22
+ to: email,
23
+ subject: t("Verify Email"),
24
+ text: t("Verify your email address with your code: {code}", { code }),
25
+ });
26
+ }
27
+ export async function sendResetPassword(email, code) {
28
+ const t = await getMailTranslator();
29
+ return sendMail({
30
+ to: email,
31
+ subject: t("Password Reset"),
32
+ text: t("Verify password reset with code: {code}", { code }),
33
+ });
34
+ }
35
+ export async function sendRecoveryCode(email, recoveryCode) {
36
+ const t = await getMailTranslator();
37
+ return sendMail({
38
+ to: email,
39
+ subject: t("Recovery Code"),
40
+ text: t("Your recovery code is: {recoveryCode}", { recoveryCode }),
41
+ });
42
+ }
43
+ export async function send2FACode(email, code) {
44
+ const t = await getMailTranslator();
45
+ return sendMail({
46
+ to: email,
47
+ subject: t("Your 2FA Code"),
48
+ text: t("Your verification code is: {code}. It will expire in 10 minutes.", {
49
+ code,
50
+ }),
51
+ });
52
+ }
@@ -0,0 +1,38 @@
1
+ import type { Token } from "@veap/kernel";
2
+ import type { MailMessage } from "./mail-message.js";
3
+ /**
4
+ * Mailer port.
5
+ *
6
+ * A pure delivery service: it transmits `MailMessage`s and knows nothing
7
+ * about *why* an email is being sent. Semantics - verification codes, resets,
8
+ * 2FA, templates, i18n - belong to the domains that own those flows and are
9
+ * expressed as mailables (`application/communication/mail.ts`), which build a
10
+ * `MailMessage` and hand it to this port.
11
+ *
12
+ * This mirrors Laravel's split: `Transport` (the `MailManager`'s drivers)
13
+ * only delivers a raw message, while `Mailable` classes define the content.
14
+ */
15
+ export interface IMailer {
16
+ /** Delivers a message. The single, transport-agnostic operation. */
17
+ sendMail(message: MailMessage): Promise<void>;
18
+ }
19
+ /**
20
+ * Injection token for the mailer port.
21
+ *
22
+ * A global symbol keeps the token stable across HMR / dual-package
23
+ * boundaries, the same reason the container keys class tokens with
24
+ * `Symbol.for(...)`.
25
+ */
26
+ export declare const MAILER: Token<IMailer>;
27
+ /**
28
+ * Backward compatibility alias for the mailer port token.
29
+ */
30
+ export declare const MAIL_SERVICE: Token<IMailer>;
31
+ /**
32
+ * Injection token for a custom transport supplied by the host application.
33
+ *
34
+ * Set `MAIL_TRANSPORT=custom` and register an `IMailer` implementation under
35
+ * this token (e.g. SES, Postmark, Resend, an outbox pattern…) to route all
36
+ * mail through your own delivery system without touching the framework.
37
+ */
38
+ export declare const CUSTOM_MAILER: Token<IMailer>;
package/dist/mailer.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Injection token for the mailer port.
3
+ *
4
+ * A global symbol keeps the token stable across HMR / dual-package
5
+ * boundaries, the same reason the container keys class tokens with
6
+ * `Symbol.for(...)`.
7
+ */
8
+ export const MAILER = Symbol.for("veap:communication:mailer");
9
+ /**
10
+ * Backward compatibility alias for the mailer port token.
11
+ */
12
+ export const MAIL_SERVICE = MAILER;
13
+ /**
14
+ * Injection token for a custom transport supplied by the host application.
15
+ *
16
+ * Set `MAIL_TRANSPORT=custom` and register an `IMailer` implementation under
17
+ * this token (e.g. SES, Postmark, Resend, an outbox pattern…) to route all
18
+ * mail through your own delivery system without touching the framework.
19
+ */
20
+ export const CUSTOM_MAILER = Symbol.for("veap:communication:custom-mailer");
@@ -0,0 +1,19 @@
1
+ import { ServiceProvider } from "@veap/kernel";
2
+ /**
3
+ * Composition root of the communication context.
4
+ *
5
+ * Selects the mail transport exactly once (in `boot()`) from `MAIL_TRANSPORT`
6
+ * and binds it into `CommunicationContext`, so facades and services never
7
+ * look transports up themselves:
8
+ *
9
+ * - `smtp` (default) - Nodemailer (`MAIL_SERVICE`, `MAIL_HOST`, `MAIL_PORT`,
10
+ * `MAIL_USERNAME`/`MAIL_PASSWORD` or `GOOGLE_SMTP_APP_*`, `MAIL_FROM_ADDRESS`)
11
+ * - `console` - logs the message; dev/test with zero mail configuration
12
+ * - `custom` - resolves `CUSTOM_MAILER`; bring your own SES/Postmark/Resend…
13
+ */
14
+ export declare class CommunicationServiceProvider extends ServiceProvider {
15
+ register(): void;
16
+ boot(): Promise<void>;
17
+ private selectTransport;
18
+ }
19
+ export declare const MailServiceProvider: typeof CommunicationServiceProvider;
@@ -0,0 +1,103 @@
1
+ import { ConfigService, logger, ServiceProvider } from "@veap/kernel";
2
+ import { bindCommunicationContext } from "./context.js";
3
+ import { CUSTOM_MAILER, MAILER } from "./mailer.js";
4
+ import { ConsoleMailService } from "./providers/console.provider.js";
5
+ import { NodemailerMailService } from "./providers/nodemailer.provider.js";
6
+ import { setMailTranslatorFactory } from "./translator.js";
7
+ /**
8
+ * Composition root of the communication context.
9
+ *
10
+ * Selects the mail transport exactly once (in `boot()`) from `MAIL_TRANSPORT`
11
+ * and binds it into `CommunicationContext`, so facades and services never
12
+ * look transports up themselves:
13
+ *
14
+ * - `smtp` (default) - Nodemailer (`MAIL_SERVICE`, `MAIL_HOST`, `MAIL_PORT`,
15
+ * `MAIL_USERNAME`/`MAIL_PASSWORD` or `GOOGLE_SMTP_APP_*`, `MAIL_FROM_ADDRESS`)
16
+ * - `console` - logs the message; dev/test with zero mail configuration
17
+ * - `custom` - resolves `CUSTOM_MAILER`; bring your own SES/Postmark/Resend…
18
+ */
19
+ export class CommunicationServiceProvider extends ServiceProvider {
20
+ register() {
21
+ // Default registration: the Nodemailer transport under the port token,
22
+ // with config getters wired here at the composition root (the adapter
23
+ // itself takes plain functions, so it stays container-agnostic).
24
+ // Replaced with a `useValue` binding in `boot()` when MAIL_TRANSPORT
25
+ // selects another transport.
26
+ this.container.register({
27
+ token: MAILER,
28
+ useFactory: (config) => {
29
+ const getEnv = (key) => {
30
+ return (config?.get?.(key) ??
31
+ (typeof process !== "undefined" ? process.env[key] : undefined));
32
+ };
33
+ return new NodemailerMailService({
34
+ getService: () => getEnv("MAIL_SERVICE"),
35
+ getHost: () => getEnv("MAIL_HOST"),
36
+ getPort: () => getEnv("MAIL_PORT"),
37
+ getSecure: () => {
38
+ const val = getEnv("MAIL_SECURE");
39
+ return val ? val === "true" || val === "1" : undefined;
40
+ },
41
+ getIgnoreTLS: () => {
42
+ const val = getEnv("MAIL_IGNORE_TLS");
43
+ return val ? val === "true" || val === "1" : undefined;
44
+ },
45
+ getUsername: () => getEnv("GOOGLE_SMTP_APP_USERNAME") ?? getEnv("MAIL_USERNAME"),
46
+ getPassword: () => getEnv("GOOGLE_SMTP_APP_PASSWORD") ?? getEnv("MAIL_PASSWORD"),
47
+ getFromAddress: () => getEnv("MAIL_FROM_ADDRESS"),
48
+ getFromName: () => getEnv("MAIL_FROM_NAME"),
49
+ });
50
+ },
51
+ inject: [ConfigService],
52
+ singleton: true,
53
+ });
54
+ }
55
+ async boot() {
56
+ // Bind the default translation factory that supports `{placeholder}`
57
+ // interpolation into the message text.
58
+ setMailTranslatorFactory(async () => (key, params) => {
59
+ if (!params)
60
+ return key;
61
+ return Object.entries(params).reduce((acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)), key);
62
+ });
63
+ const config = this.container.has(ConfigService)
64
+ ? await this.container.resolve(ConfigService)
65
+ : undefined;
66
+ const mailer = await this.selectTransport(config);
67
+ // Rebind the port token to the selected transport and expose it through
68
+ // the typed context boundary.
69
+ this.container.register({
70
+ token: MAILER,
71
+ useValue: mailer,
72
+ singleton: true,
73
+ });
74
+ bindCommunicationContext({ mailer });
75
+ logger.debug("veap:communication", `Mail transport bound: ${mailer.constructor.name}`);
76
+ }
77
+ async selectTransport(config) {
78
+ const getVal = (key) => {
79
+ return (config?.get?.(key) ??
80
+ (typeof process !== "undefined" ? process.env[key] : undefined));
81
+ };
82
+ const transport = getVal("MAIL_TRANSPORT")?.trim().toLowerCase();
83
+ if (transport === "custom") {
84
+ if (!this.container.has(CUSTOM_MAILER)) {
85
+ throw new Error("[Communication] MAIL_TRANSPORT=custom but no provider is registered under the CUSTOM_MAILER token (register an IMailer in a ServiceProvider).");
86
+ }
87
+ return this.container.resolve(CUSTOM_MAILER);
88
+ }
89
+ if (transport === "console") {
90
+ return new ConsoleMailService((line) => logger.info("veap:mail", line));
91
+ }
92
+ const hasSmtpCredentials = Boolean(getVal("GOOGLE_SMTP_APP_USERNAME") || getVal("MAIL_USERNAME")) &&
93
+ Boolean(getVal("GOOGLE_SMTP_APP_PASSWORD") || getVal("MAIL_PASSWORD"));
94
+ const hasSmtpHostOrService = Boolean(getVal("MAIL_HOST") || getVal("MAIL_SERVICE"));
95
+ if (!transport && !hasSmtpCredentials && !hasSmtpHostOrService) {
96
+ logger.warn("veap:communication", "MAIL_TRANSPORT is not set and no SMTP credentials (MAIL_USERNAME/MAIL_PASSWORD) or host (MAIL_HOST) are configured. Defaulting to ConsoleMailService (emails will be logged to server console).");
97
+ return new ConsoleMailService((line) => logger.info("veap:mail", line));
98
+ }
99
+ // Default ("smtp" / unset with credentials/host): whatever register() wired - Nodemailer.
100
+ return this.container.resolve(MAILER);
101
+ }
102
+ }
103
+ export const MailServiceProvider = CommunicationServiceProvider;
@@ -0,0 +1,14 @@
1
+ import type { MailMessage } from "../mail-message.js";
2
+ import type { IMailer } from "../mailer.js";
3
+ /**
4
+ * Console transport implementing the {@link IMailer} port.\n *
5
+ * A pure delivery service for development and testing: prints the
6
+ * `MailMessage` instead of dialling an SMTP server. Selected with
7
+ * `MAIL_TRANSPORT=console`. It knows nothing about message semantics -
8
+ * content comes fully built from the mailables.
9
+ */
10
+ export declare class ConsoleMailService implements IMailer {
11
+ private readonly log;
12
+ constructor(log?: (line: string) => void);
13
+ sendMail(message: MailMessage): Promise<void>;
14
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Console transport implementing the {@link IMailer} port.\n *
3
+ * A pure delivery service for development and testing: prints the
4
+ * `MailMessage` instead of dialling an SMTP server. Selected with
5
+ * `MAIL_TRANSPORT=console`. It knows nothing about message semantics -
6
+ * content comes fully built from the mailables.
7
+ */
8
+ export class ConsoleMailService {
9
+ log;
10
+ constructor(log = console.info) {
11
+ this.log = log;
12
+ }
13
+ async sendMail(message) {
14
+ const to = Array.isArray(message.to)
15
+ ? message.to
16
+ .map((r) => (typeof r === "string" ? r : r.address))
17
+ .join(", ")
18
+ : typeof message.to === "string"
19
+ ? message.to
20
+ : message.to.address;
21
+ this.log([
22
+ `📬 [MAIL:console] to=${to}`,
23
+ `subject="${message.subject}"`,
24
+ message.text ? `text="${message.text}"` : null,
25
+ message.html ? `(html ${message.html.length} bytes)` : null,
26
+ message.attachments?.length
27
+ ? `(attachments: ${message.attachments.length})`
28
+ : null,
29
+ ]
30
+ .filter(Boolean)
31
+ .join(" "));
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ import type { SendMailOptions } from "nodemailer";
2
+ import type { MailMessage, MailRecipient } from "../mail-message.js";
3
+ import type { IMailer } from "../mailer.js";
4
+ export interface NodemailerMailConfig {
5
+ getService?: () => string | undefined;
6
+ getHost?: () => string | undefined;
7
+ getPort?: () => number | string | undefined;
8
+ getSecure?: () => boolean | undefined;
9
+ getIgnoreTLS?: () => boolean | undefined;
10
+ getUsername?: () => string | undefined;
11
+ getPassword?: () => string | undefined;
12
+ getFromAddress?: () => string | undefined;
13
+ getFromName?: () => string | undefined;
14
+ }
15
+ /**
16
+ * SMTP transport (Nodemailer) implementing the {@link IMailer} port.
17
+ *
18
+ * A pure delivery service: takes `MailMessage`s and transmits them. It knows
19
+ * nothing about verification codes, resets or i18n - content is decided by
20
+ * the mailables in `application/communication/mail.ts` (the Laravel split:
21
+ * `Mailable` = content, `Transport` = delivery).
22
+ *
23
+ * The only place in the codebase that knows Nodemailer exists; Nodemailer's
24
+ * field names are a superset of the DTO, so mapping is one spread plus a
25
+ * default sender.
26
+ */
27
+ export declare class NodemailerMailService implements IMailer {
28
+ private transporter;
29
+ private fromAddress;
30
+ constructor(config: NodemailerMailConfig);
31
+ /** The sender used when a message does not specify one. */
32
+ get defaultFrom(): MailRecipient;
33
+ /** Exposed for tests and provider-agnostic integrations. */
34
+ toSendMailOptions(message: MailMessage): SendMailOptions;
35
+ sendMail(message: MailMessage): Promise<void>;
36
+ }
@@ -0,0 +1,86 @@
1
+ import nodemailer from "nodemailer";
2
+ /**
3
+ * SMTP transport (Nodemailer) implementing the {@link IMailer} port.
4
+ *
5
+ * A pure delivery service: takes `MailMessage`s and transmits them. It knows
6
+ * nothing about verification codes, resets or i18n - content is decided by
7
+ * the mailables in `application/communication/mail.ts` (the Laravel split:
8
+ * `Mailable` = content, `Transport` = delivery).
9
+ *
10
+ * The only place in the codebase that knows Nodemailer exists; Nodemailer's
11
+ * field names are a superset of the DTO, so mapping is one spread plus a
12
+ * default sender.
13
+ */
14
+ export class NodemailerMailService {
15
+ transporter;
16
+ fromAddress;
17
+ constructor(config) {
18
+ const fromAddress = config.getFromAddress?.();
19
+ const fromName = config.getFromName?.();
20
+ const username = config.getUsername?.();
21
+ const password = config.getPassword?.();
22
+ if (fromAddress) {
23
+ this.fromAddress =
24
+ fromName && !fromAddress.includes("<")
25
+ ? `${fromName} <${fromAddress}>`
26
+ : fromAddress;
27
+ }
28
+ else if (username) {
29
+ this.fromAddress = fromName
30
+ ? `${fromName} <${username}>`
31
+ : `Veap <${username}>`;
32
+ }
33
+ else {
34
+ this.fromAddress = "Veap";
35
+ }
36
+ const host = config.getHost?.();
37
+ const port = config.getPort?.();
38
+ const service = config.getService?.();
39
+ const secure = config.getSecure?.();
40
+ const ignoreTLS = config.getIgnoreTLS?.();
41
+ const auth = username || password
42
+ ? {
43
+ user: username,
44
+ pass: password,
45
+ }
46
+ : undefined;
47
+ if (host) {
48
+ const parsedPort = port ? Number(port) : 587;
49
+ this.transporter = nodemailer.createTransport({
50
+ host,
51
+ port: parsedPort,
52
+ secure: secure !== undefined ? Boolean(secure) : parsedPort === 465,
53
+ ignoreTLS: Boolean(ignoreTLS),
54
+ auth,
55
+ });
56
+ }
57
+ else if (service) {
58
+ this.transporter = nodemailer.createTransport({
59
+ service,
60
+ auth,
61
+ });
62
+ }
63
+ else {
64
+ // Default local or fallback SMTP transport
65
+ this.transporter = nodemailer.createTransport({
66
+ host: "localhost",
67
+ port: 587,
68
+ auth,
69
+ });
70
+ }
71
+ }
72
+ /** The sender used when a message does not specify one. */
73
+ get defaultFrom() {
74
+ return this.fromAddress;
75
+ }
76
+ /** Exposed for tests and provider-agnostic integrations. */
77
+ toSendMailOptions(message) {
78
+ return {
79
+ ...message,
80
+ from: message.from ?? this.fromAddress,
81
+ };
82
+ }
83
+ async sendMail(message) {
84
+ await this.transporter.sendMail(this.toSendMailOptions(message));
85
+ }
86
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Translation hook for mailables.
3
+ *
4
+ * Lives in the application layer because building localized messages is a
5
+ * use-case concern; the intl implementation (infrastructure) is injected here
6
+ * once by `CommunicationServiceProvider` - so mailables never import intl
7
+ * directly and the package never imports its own entry points (ADR-006).
8
+ */
9
+ export type Translator = (key: string, params?: Record<string, unknown>) => string;
10
+ /**
11
+ * Helper to interpolate template placeholders like `{name}` or `{code}`.
12
+ */
13
+ export declare function interpolateMailText(text: string, params?: Record<string, unknown>): string;
14
+ /**
15
+ * Binds the translation factory. Invoked per message build, so the locale
16
+ * always reflects the current request.
17
+ */
18
+ export declare function setMailTranslatorFactory(factory: () => Promise<Translator>): void;
19
+ /**
20
+ * Returns the bound translator, falling back to simple `{param}`
21
+ * interpolation into the raw key when intl has not bound one yet
22
+ * (unit tests, early boot).
23
+ */
24
+ export declare function getMailTranslator(): Promise<Translator>;
@@ -0,0 +1,31 @@
1
+ let translatorFactory = null;
2
+ /**
3
+ * Helper to interpolate template placeholders like `{name}` or `{code}`.
4
+ */
5
+ export function interpolateMailText(text, params) {
6
+ if (!params)
7
+ return text;
8
+ return Object.entries(params).reduce((acc, [k, v]) => acc.replaceAll(`{${k}}`, String(v)), text);
9
+ }
10
+ /**
11
+ * Binds the translation factory. Invoked per message build, so the locale
12
+ * always reflects the current request.
13
+ */
14
+ export function setMailTranslatorFactory(factory) {
15
+ translatorFactory = factory;
16
+ }
17
+ /**
18
+ * Returns the bound translator, falling back to simple `{param}`
19
+ * interpolation into the raw key when intl has not bound one yet
20
+ * (unit tests, early boot).
21
+ */
22
+ export async function getMailTranslator() {
23
+ if (translatorFactory) {
24
+ const fn = await translatorFactory();
25
+ return (key, params) => {
26
+ const translated = fn(key, params);
27
+ return interpolateMailText(translated, params);
28
+ };
29
+ }
30
+ return (key, params) => interpolateMailText(key, params);
31
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@veap/mail",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.1",
4
+ "description": "Mail and communication transport abstraction for Veap",
5
+ "bugs": "https://github.com/veapjs/veap-mail/issues",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/veapjs/veap-mail.git"
9
+ },
10
+ "type": "module",
11
+ "license": "MIT",
12
+ "main": "./dist/index.js",
13
+ "types": "./dist/index.d.ts",
14
+ "exports": {
15
+ ".": {
16
+ "types": "./dist/index.d.ts",
17
+ "default": "./dist/index.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "scripts": {
25
+ "clean": "rimraf dist",
26
+ "build": "rimraf dist && tsc",
27
+ "dev": "tsc --watch",
28
+ "test": "vitest run"
29
+ },
30
+ "dependencies": {
31
+ "@veap/kernel": "0.1.1",
32
+ "nodemailer": "^10.0.1"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^22.20.2",
36
+ "@types/nodemailer": "^8.0.1",
37
+ "rimraf": "^6.1.3",
38
+ "typescript": "6.0.3",
39
+ "vitest": "^5.0.0"
40
+ }
41
+ }