From 648b1d464a27f5da2f3d66cad0523b3797b83aca Mon Sep 17 00:00:00 2001 From: fiandev Date: Sun, 19 Oct 2025 23:57:26 +0700 Subject: [PATCH 1/3] feat(mailer): add mailer plugin Adds a new mailer plugin with functionality to send emails using Nodemailer. Includes configuration, types, and basic mail creation. --- package.json | 3 +- plugins/mailer/CHANGELOG.md | 3 ++ plugins/mailer/Mail.ts | 28 ++++++++++++ plugins/mailer/Mailer.ts | 85 +++++++++++++++++++++++++++++++++++ plugins/mailer/config.ts | 3 ++ plugins/mailer/index.ts | 2 + plugins/mailer/mailer.test.ts | 0 plugins/mailer/package.json | 25 +++++++++++ plugins/mailer/types.ts | 26 +++++++++++ 9 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 plugins/mailer/CHANGELOG.md create mode 100644 plugins/mailer/Mail.ts create mode 100644 plugins/mailer/Mailer.ts create mode 100644 plugins/mailer/config.ts create mode 100644 plugins/mailer/index.ts create mode 100644 plugins/mailer/mailer.test.ts create mode 100644 plugins/mailer/package.json create mode 100644 plugins/mailer/types.ts diff --git a/package.json b/package.json index 1d1482d..5b39c96 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "plugins/session", "plugins/rate-limit", "plugins/edge", - "plugins/jwt" + "plugins/jwt", + "plugins/mailer" ], "author": "angga7togk", "license": "MIT", diff --git a/plugins/mailer/CHANGELOG.md b/plugins/mailer/CHANGELOG.md new file mode 100644 index 0000000..2a5a0d1 --- /dev/null +++ b/plugins/mailer/CHANGELOG.md @@ -0,0 +1,3 @@ +# @gaman/mailer + +## 1.0.0 diff --git a/plugins/mailer/Mail.ts b/plugins/mailer/Mail.ts new file mode 100644 index 0000000..6671669 --- /dev/null +++ b/plugins/mailer/Mail.ts @@ -0,0 +1,28 @@ +export interface LikeMail { + from: string; + to: string; + subject: string; + text: string; + body: string | Response; +} + +export default class Mail { + from: string; + to: string; + subject: string; + text: string; + body: string | Response; + + async create(mail: LikeMail) { + this.from = mail.from; + this.to = mail.to; + this.subject = mail.subject; + this.text = mail.text; + + if (typeof mail.body !== 'string') { + this.body = await mail.body.text(); + } + + this.body = mail.body; + } +} diff --git a/plugins/mailer/Mailer.ts b/plugins/mailer/Mailer.ts new file mode 100644 index 0000000..509bdb6 --- /dev/null +++ b/plugins/mailer/Mailer.ts @@ -0,0 +1,85 @@ +import nodemailer from 'nodemailer'; +import { Mail, MailerSentOptions, Transport } from './types'; +import { + DEFAULT_SMTP_HOST, + DEFAULT_SMTP_PORT, + DEFAULT_SMTP_SECURE, +} from './config'; + +export default class Mailer { + private transporter: Transport; + + constructor(private trans: Transport) { + this.transporter = { + host: trans.host || DEFAULT_SMTP_HOST, + port: trans.port || DEFAULT_SMTP_PORT, + secure: trans.secure || DEFAULT_SMTP_SECURE, + auth: { + user: trans.auth.user, + password: trans.auth.password, + }, + }; + } + + async wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + async sendMail(mail: Mail, options: MailerSentOptions): Promise { + return new Promise(async (resolve, reject) => { + if (!this.transporter) { + reject(new Error('Transport not initialized')); + return; + } + + const transport = nodemailer.createTransport(this.transporter); + + if (options.delay) { + await this.wait(options.delay); + } + + if (options.timeout) { + setTimeout(() => { + console.error('Timeout'); + reject(false); + }, options.timeout); + } + + if (options.scheduled_at) { + const scheduledDate = new Date(options.scheduled_at); + const now = new Date(); + + setTimeout(async () => { + await this.sendMail(mail, options); + }, scheduledDate.getTime() - now.getTime()); + } + + transport.sendMail( + { + from: mail.from, + to: mail.to, + subject: mail.subject, + text: mail.text, + html: mail.body, + }, + (error, info) => { + if (error) { + if (options.debug) { + console.error(error); + } + + reject(false); + } else { + if (options.debug) { + console.table(info); + } + + resolve(true); + } + }, + ); + + // end method + }); + } +} diff --git a/plugins/mailer/config.ts b/plugins/mailer/config.ts new file mode 100644 index 0000000..d017052 --- /dev/null +++ b/plugins/mailer/config.ts @@ -0,0 +1,3 @@ +export const DEFAULT_SMTP_HOST = 'smtp.example.com'; +export const DEFAULT_SMTP_PORT = 587; +export const DEFAULT_SMTP_SECURE = false; diff --git a/plugins/mailer/index.ts b/plugins/mailer/index.ts new file mode 100644 index 0000000..1b7073d --- /dev/null +++ b/plugins/mailer/index.ts @@ -0,0 +1,2 @@ +export * from './Mail'; +export * from './Mailer'; diff --git a/plugins/mailer/mailer.test.ts b/plugins/mailer/mailer.test.ts new file mode 100644 index 0000000..e69de29 diff --git a/plugins/mailer/package.json b/plugins/mailer/package.json new file mode 100644 index 0000000..18ec11b --- /dev/null +++ b/plugins/mailer/package.json @@ -0,0 +1,25 @@ +{ + "name": "@gaman/mailer", + "version": "1.0.0", + "type": "module", + "main": "index.js", + "author": "angga7togk", + "license": "MIT", + "repository": { + "url": "git+https://github.com/7TogkID/gaman.git", + "directory": "plugins/mailer" + }, + "bugs": { + "url": "https://github.com/7TogkID/gaman/issues" + }, + "homepage": "https://gaman.7togk.id", + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "gitHead": "8d1bf3bbac4b72847e9157e2c95d75b5e90c89cf", + "peerDependencies": { + "@types/nodemailer": "^7.0.2", + "nodemailer": "^7.0.9" + } +} diff --git a/plugins/mailer/types.ts b/plugins/mailer/types.ts new file mode 100644 index 0000000..5d59ecf --- /dev/null +++ b/plugins/mailer/types.ts @@ -0,0 +1,26 @@ +export interface Credentials { + user: string; + password: string; +} + +export interface Transport { + host?: string; + port?: number; + secure?: boolean; + auth: Credentials; +} + +export interface Mail { + from: string; + to: string; + subject: string; + text: string; + body: string; +} + +export interface MailerSentOptions { + debug?: boolean; + delay?: number; + timeout?: number; + scheduled_at?: string; +} From 6218a82fc0b9be3cdf054d5b9aa8096f19ddba14 Mon Sep 17 00:00:00 2001 From: fiandev Date: Mon, 20 Oct 2025 00:02:21 +0700 Subject: [PATCH 2/3] feat(mailer): improve mail and mailer implementation Refactors Mail class to import types and adds comprehensive tests for Mailer and Mail classes, covering various scenarios like error handling, delays, timeouts, and scheduled emails. --- plugins/mailer/Mail.ts | 12 +- plugins/mailer/mailer.test.ts | 201 ++++++++++++++++++++++++++++++++++ plugins/mailer/types.ts | 2 +- 3 files changed, 205 insertions(+), 10 deletions(-) diff --git a/plugins/mailer/Mail.ts b/plugins/mailer/Mail.ts index 6671669..587167e 100644 --- a/plugins/mailer/Mail.ts +++ b/plugins/mailer/Mail.ts @@ -1,10 +1,4 @@ -export interface LikeMail { - from: string; - to: string; - subject: string; - text: string; - body: string | Response; -} +import { Mail as LikeMail } from './types'; export default class Mail { from: string; @@ -21,8 +15,8 @@ export default class Mail { if (typeof mail.body !== 'string') { this.body = await mail.body.text(); + } else { + this.body = mail.body; } - - this.body = mail.body; } } diff --git a/plugins/mailer/mailer.test.ts b/plugins/mailer/mailer.test.ts index e69de29..493a87e 100644 --- a/plugins/mailer/mailer.test.ts +++ b/plugins/mailer/mailer.test.ts @@ -0,0 +1,201 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import nodemailer from 'nodemailer'; +import Mailer from './Mailer'; +import Mail from './Mail'; +import { Transport, Mail as MailType } from './types'; + +// Mock nodemailer +vi.mock('nodemailer', () => ({ + default: { + createTransport: vi.fn(), + }, +})); + +const mockTransporter = { + sendMail: vi.fn(), +}; + +describe('Mailer', () => { + let mailer: Mailer; + let transport: Transport; + + beforeEach(() => { + transport = { + host: 'smtp.example.com', + port: 587, + secure: false, + auth: { + user: 'test@example.com', + password: 'password', + }, + }; + + mailer = new Mailer(transport); + + // Reset mocks + vi.clearAllMocks(); + + // Setup default mock for createTransport + (nodemailer.createTransport as any).mockReturnValue(mockTransporter); + }); + + describe('sendMail', () => { + it('should send an email successfully', async () => { + const mail: MailType = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + const options = {}; + + mockTransporter.sendMail.mockImplementation((_, callback) => { + callback(null, { messageId: 'test-id' }); + }); + + const result = await mailer.sendMail(mail, options); + + expect(result).toBe(true); + expect(nodemailer.createTransport).toHaveBeenCalledWith(transport); + expect(mockTransporter.sendMail).toHaveBeenCalledWith( + { + from: mail.from, + to: mail.to, + subject: mail.subject, + text: mail.text, + html: mail.body, + }, + expect.any(Function), + ); + }); + + it('should handle send mail error', async () => { + const mail: MailType = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + const options = {}; + + const error = new Error('Send failed'); + mockTransporter.sendMail.mockImplementation((_, callback) => { + callback(error, null); + }); + + await expect(mailer.sendMail(mail, options)).rejects.toBe(false); + }); + + it('should apply delay option', async () => { + const mail: MailType = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + const options = { delay: 100 }; + + mockTransporter.sendMail.mockImplementation((_, callback) => { + callback(null, { messageId: 'test-id' }); + }); + + const start = Date.now(); + await mailer.sendMail(mail, options); + const end = Date.now(); + + expect(end - start).toBeGreaterThanOrEqual(100); + }); + + it('should handle timeout option', async () => { + const mail: MailType = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + const options = { timeout: 50 }; + + // Mock sendMail to not call callback immediately + mockTransporter.sendMail.mockImplementation(() => { + // Do not call callback to trigger timeout + }); + + await expect(mailer.sendMail(mail, options)).rejects.toBe(false); + }); + + it('should handle scheduled_at option', async () => { + const mail: MailType = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + const futureDate = new Date(Date.now() + 100).toISOString(); + const options = { scheduled_at: futureDate }; + + mockTransporter.sendMail.mockImplementation((_, callback) => { + callback(null, { messageId: 'test-id' }); + }); + + // Since it's scheduled, sendMail should resolve immediately without sending + const result = await mailer.sendMail(mail, options); + expect(result).toBe(true); + + // The actual send should be scheduled, but in test, we can't easily verify the setTimeout + // This test mainly checks that it doesn't throw and resolves + }); + + it('should throw if transport auth is missing', async () => { + expect(() => new Mailer({} as Transport)).toThrow( + "Cannot read properties of undefined (reading 'user')", + ); + }); + }); +}); + +describe('Mail', () => { + it('should create mail object correctly', async () => { + const mail = new Mail(); + const mailData = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: 'Test body', + }; + + await mail.create(mailData); + + expect(mail.from).toBe(mailData.from); + expect(mail.to).toBe(mailData.to); + expect(mail.subject).toBe(mailData.subject); + expect(mail.text).toBe(mailData.text); + expect(mail.body).toBe(mailData.body); + }); + + it('should handle Response body', async () => { + const mail = new Mail(); + const response = new Response('Response body text'); + const mailData = { + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Subject', + text: 'Test text', + body: response, + }; + + await mail.create(mailData); + + expect(mail.body).toBe('Response body text'); + }); +}); diff --git a/plugins/mailer/types.ts b/plugins/mailer/types.ts index 5d59ecf..90babae 100644 --- a/plugins/mailer/types.ts +++ b/plugins/mailer/types.ts @@ -15,7 +15,7 @@ export interface Mail { to: string; subject: string; text: string; - body: string; + body: string | Response; } export interface MailerSentOptions { From 035bbabdd9dba6dd0037d7fff69f73278ab45e75 Mon Sep 17 00:00:00 2001 From: fiandev Date: Mon, 20 Oct 2025 00:06:29 +0700 Subject: [PATCH 3/3] feat(mailer): initial release of mailer plugin Adds the Gaman mailer plugin with support for sending emails via SMTP using nodemailer. Includes Mail and Mailer classes for composing and sending emails, with options for delayed sending, timeouts, scheduling, and debug mode. --- plugins/mailer/CHANGELOG.md | 11 ++++- plugins/mailer/Mail.ts | 2 +- plugins/mailer/Mailer.ts | 2 +- plugins/mailer/README.md | 90 +++++++++++++++++++++++++++++++++++++ 4 files changed, 101 insertions(+), 4 deletions(-) create mode 100644 plugins/mailer/README.md diff --git a/plugins/mailer/CHANGELOG.md b/plugins/mailer/CHANGELOG.md index 2a5a0d1..60311d1 100644 --- a/plugins/mailer/CHANGELOG.md +++ b/plugins/mailer/CHANGELOG.md @@ -1,3 +1,10 @@ -# @gaman/mailer + # @gaman/mailer -## 1.0.0 + ## 1.0.0 + + - Initial release of the Gaman mailer plugin + - Added support for sending emails using SMTP via nodemailer + - Introduced Mail class for composing email messages with from, to, subject, text, and HTML body + - Added Mailer class with features for delayed sending, timeouts, scheduling, and debug mode + - Configurable SMTP settings with defaults for host, port, and security + - Peer dependencies: nodemailer and @types/nodemailer diff --git a/plugins/mailer/Mail.ts b/plugins/mailer/Mail.ts index 587167e..528492b 100644 --- a/plugins/mailer/Mail.ts +++ b/plugins/mailer/Mail.ts @@ -5,7 +5,7 @@ export default class Mail { to: string; subject: string; text: string; - body: string | Response; + body: string; async create(mail: LikeMail) { this.from = mail.from; diff --git a/plugins/mailer/Mailer.ts b/plugins/mailer/Mailer.ts index 509bdb6..bebe09c 100644 --- a/plugins/mailer/Mailer.ts +++ b/plugins/mailer/Mailer.ts @@ -60,7 +60,7 @@ export default class Mailer { to: mail.to, subject: mail.subject, text: mail.text, - html: mail.body, + html: mail.body as string, }, (error, info) => { if (error) { diff --git a/plugins/mailer/README.md b/plugins/mailer/README.md new file mode 100644 index 0000000..b746fa9 --- /dev/null +++ b/plugins/mailer/README.md @@ -0,0 +1,90 @@ +# @gaman/mailer + +A plugin for the Gaman framework to send emails using SMTP via nodemailer. + +## Installation + +Ensure you have the required peer dependencies installed: + +```bash +npm install @gaman/mailer +``` + +## Configuration + +Configure your SMTP settings in your Gaman application. You can customize the transport options: + +```typescript +import { Transport } from '@gaman/mailer'; + +const transport: Transport = { + host: 'smtp.gmail.com', + port: 587, + secure: false, // true for 465, false for other ports + auth: { + user: 'your-email@example.com', + password: 'your-password', + }, +}; +``` + +Defaults are provided if not specified: +- Host: `smtp.example.com` +- Port: `587` +- Secure: `false` + +## Usage + +### Step 1: Create a Mail Instance + +Use the `Mail` class to compose your email: + +```typescript +import Mail from '@gaman/mailer/Mail'; + +const mail = new Mail(); +await mail.create({ + from: 'sender@example.com', + to: 'recipient@example.com', + subject: 'Test Email', + text: 'This is a plain text email.', + body: '

This is an HTML email.

', // or a Response object for dynamic content +}); +``` + +### Step 2: Send the Email + +Use the `Mailer` class to send the email: + +```typescript +import Mailer from '@gaman/mailer/Mailer'; +import { MailerSentOptions } from '@gaman/mailer'; + +const mailer = new Mailer(transport); + +const options: MailerSentOptions = { + debug: true, // Enable debug logging + delay: 1000, // Delay sending by 1 second (optional) + timeout: 5000, // Timeout after 5 seconds (optional) + scheduled_at: '2023-10-01T10:00:00Z', // Schedule for later (optional) +}; + +try { + const success = await mailer.sendMail(mail, options); + if (success) { + console.log('Email sent successfully'); + } +} catch (error) { + console.error('Failed to send email:', error); +} +``` + +### API Reference + +- **Mail Class**: Composes email messages. + - `create(mail: LikeMail): Promise`: Initializes the mail object. + +- **Mailer Class**: Handles sending emails. + - `sendMail(mail: Mail, options: MailerSentOptions): Promise`: Sends the email and returns success status. + +For more details, refer to the source code in `Mail.ts` and `Mailer.ts`.