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..60311d1 --- /dev/null +++ b/plugins/mailer/CHANGELOG.md @@ -0,0 +1,10 @@ + # @gaman/mailer + + ## 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 new file mode 100644 index 0000000..528492b --- /dev/null +++ b/plugins/mailer/Mail.ts @@ -0,0 +1,22 @@ +import { Mail as LikeMail } from './types'; + +export default class Mail { + from: string; + to: string; + subject: string; + text: string; + body: string; + + 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(); + } else { + this.body = mail.body; + } + } +} diff --git a/plugins/mailer/Mailer.ts b/plugins/mailer/Mailer.ts new file mode 100644 index 0000000..bebe09c --- /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 as string, + }, + (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/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`. 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..493a87e --- /dev/null +++ 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/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..90babae --- /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 | Response; +} + +export interface MailerSentOptions { + debug?: boolean; + delay?: number; + timeout?: number; + scheduled_at?: string; +}