BREAKING CHANGE(smartmail): Improve email validation and Smartmail features: add detailed validation for email parts, caching for MX lookups, multi-recipient support, custom headers, and update dependency imports and build scripts.

This commit is contained in:
Philipp Kunz 2025-05-07 13:18:41 +00:00
parent 442bc5a9d9
commit e395a059a6
14 changed files with 9695 additions and 3546 deletions

View File

@ -119,6 +119,6 @@ jobs:
run: | run: |
npmci node install stable npmci node install stable
npmci npm install npmci npm install
pnpm install -g @gitzone/tsdoc pnpm install -g @git.zone/tsdoc
npmci command tsdoc npmci command tsdoc
continue-on-error: true continue-on-error: true

1
.gitignore vendored
View File

@ -18,3 +18,4 @@ dist/
dist_*/ dist_*/
# custom # custom
**/.claude/settings.local.json

33
changelog.md Normal file
View File

@ -0,0 +1,33 @@
# Changelog
## 2025-05-07 - 2.0.0 - BREAKING CHANGE(smartmail)
Improve email validation and Smartmail features: add detailed validation for email parts, caching for MX lookups, multi-recipient support, custom headers, and update dependency imports and build scripts.
- Updated dependency references from '@gitzone/*' to '@git.zone/*'
- Enhanced EmailAddressValidator with RFC 5322 compliance, local part, domain part validation, and detailed result fields
- Implemented caching mechanism for MX record lookups
- Expanded Smartmail class: added support for multiple recipients, reply-to, custom headers, and MIME formatting
- Updated build scripts, package configuration, and documentation with more comprehensive usage examples and tests
## 2024-05-29 - 1.0.24 - misc
This release improved several project configurations and documentation details.
- docs: Updated the project description.
- config: Revised tsconfig settings.
- npmextra: Adjusted npmextra.json with updated githost values (applied on multiple commits).
## 2023-07-28 - 1.0.23 - misc
A couple of targeted fixes and organizational changes were introduced.
- core: Applied a core fix update.
- org: Switched to the new organization scheme.
## 2023-06-13 - 1.0.22 - core
Over a longer period (versions 1.0.22 down to 1.0.4), a series of minor core fixes were rolled out to improve stability and maintainability.
These commits—which, aside from their versionmarking commits, were solely “fix(core): update”—were consolidated into this summary.
## 2018-09-29 - 1.0.1 - initial
In the very early releases (from versions 1.0.3 to 1.0.1), the initial stabilization work set the stage for the project.
- npm: Adjusted access level (fix(npm): access level in 1.0.3).
- packagename: Updated package name details (fix(packagename): update in 1.0.2).
- core: Laid the groundwork with the initial core fix (fix(core): initial in 1.0.1).

View File

@ -1,7 +1,7 @@
{ {
"npmci": { "npmci": {
"npmGlobalTools": [ "npmGlobalTools": [
"@gitzone/npmts", "@git.zone/npmts",
"ts-node" "ts-node"
], ],
"npmAccessLevel": "public" "npmAccessLevel": "public"

View File

@ -11,19 +11,19 @@
"scripts": { "scripts": {
"test": "(tstest test/)", "test": "(tstest test/)",
"format": "(gitzone format)", "format": "(gitzone format)",
"build": "(tsbuild --web --allowimplicitany)", "build": "(tsbuild tsfolders --allowimplicitany)",
"buildDocs": "tsdoc" "buildDocs": "tsdoc"
}, },
"devDependencies": { "devDependencies": {
"@gitzone/tsbuild": "^2.1.66", "@git.zone/tsbuild": "^2.3.2",
"@gitzone/tsrun": "^1.2.44", "@git.zone/tsrun": "^1.3.3",
"@gitzone/tstest": "^1.0.77", "@git.zone/tstest": "^1.0.96",
"@push.rocks/tapbundle": "^5.0.12", "@push.rocks/tapbundle": "^6.0.3",
"@types/node": "^20.4.5" "@types/node": "^22.15.14"
}, },
"dependencies": { "dependencies": {
"@push.rocks/smartdns": "^5.0.4", "@push.rocks/smartdns": "^6.2.2",
"@push.rocks/smartfile": "^10.0.28", "@push.rocks/smartfile": "^11.2.0",
"@push.rocks/smartmustache": "^3.0.2", "@push.rocks/smartmustache": "^3.0.2",
"@push.rocks/smartpath": "^5.0.11", "@push.rocks/smartpath": "^5.0.11",
"@push.rocks/smartrequest": "^2.0.18" "@push.rocks/smartrequest": "^2.0.18"
@ -59,5 +59,6 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://code.foss.global/push.rocks/smartmail.git" "url": "https://code.foss.global/push.rocks/smartmail.git"
} },
"packageManager": "pnpm@10.10.0+sha512.d615db246fe70f25dcfea6d8d73dee782ce23e2245e3c4f6f888249fb568149318637dca73c2c5c8ef2a4ca0d5657fb9567188bfab47f566d1ee6ce987815c39"
} }

12259
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

237
readme.md
View File

@ -1,19 +1,27 @@
# @push.rocks/smartmail # @push.rocks/smartmail
a unified format for representing and dealing with mails A unified format for representing and dealing with emails
## Install ## Install
To install `@push.rocks/smartmail`, you'll need Node.js installed on your system. With Node.js installed, run the following command in your terminal: To install `@push.rocks/smartmail`, you'll need Node.js installed on your system. With Node.js installed, run the following command in your terminal:
```bash ```bash
npm install @push.rocks/smartmail --save pnpm add @push.rocks/smartmail
``` ```
This will add `@push.rocks/smartmail` to your project's dependencies. This will add `@push.rocks/smartmail` to your project's dependencies.
## Features
- **Advanced Email Address Validation**: Validate email format, check for disposable/free domains, and verify MX records
- **Rich Email Representation**: Create emails with multiple recipients, attachments, HTML content, and custom headers
- **Template Support**: Use mustache templates for dynamic content in subject, body, and HTML
- **MIME Formatting**: Convert emails to standard MIME format for sending
- **Caching & Performance**: Smart caching of DNS lookups for better performance
## Usage ## Usage
`@push.rocks/smartmail` provides a unified format for representing and dealing with emails in a Node.js environment. Below, you will find several examples showcasing how to use its main features, including email address validation and creation of mail objects with attachments. `@push.rocks/smartmail` provides a unified format for representing and dealing with emails in a Node.js environment. Below, you will find several examples showcasing how to use its main features.
### Importing the Module ### Importing the Module
@ -26,81 +34,206 @@ import {
} from '@push.rocks/smartmail'; } from '@push.rocks/smartmail';
``` ```
### Validate Email Addresses ## Email Address Validation
You can validate email addresses to check whether they are disposable, belong to a free email provider, or verify their validity in terms of having an MX record. ### Basic Email Validation
```typescript ```typescript
// Instantiate the EmailAddressValidator // Create validator with default options
const emailValidator = new EmailAddressValidator(); const emailValidator = new EmailAddressValidator();
// Validate an email address // Validate an email address
const validateEmail = async (email: string) => { const result = await emailValidator.validate('user@example.com');
const validationResult = await emailValidator.validate(email); console.log(result);
console.log(validationResult); /*
}; {
valid: true, // Overall validity
formatValid: true, // Email format is valid
localPartValid: true, // Local part (before @) is valid
domainPartValid: true, // Domain part (after @) is valid
mxValid: true, // Domain has valid MX records
disposable: false, // Not a disposable email domain
freemail: false, // Not a free email provider
reason: 'Email is valid'
}
*/
```
// Example usage ### Advanced Validation Options
validateEmail('example@gmail.com').then(() => {
console.log('Email validation completed.'); ```typescript
// Create validator with custom options
const validator = new EmailAddressValidator({
skipOnlineDomainFetch: true, // Use only local domain list
cacheDnsResults: true, // Cache DNS lookups
cacheExpiryMs: 7200000 // Cache expires after 2 hours
});
// Validate each part of an email separately
const isValidFormat = validator.isValidEmailFormat('user@example.com');
const isValidLocalPart = validator.isValidLocalPart('user');
const isValidDomain = validator.isValidDomainPart('example.com');
// Check for disposable or free email providers
const result = await validator.validate('user@gmail.com');
if (result.freemail) {
console.log('This is a free email provider');
}
```
## Creating and Using Smartmail Objects
### Basic Email Creation
```typescript
// Create a simple email
const email = new Smartmail({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Hello from SmartMail',
body: 'This is a plain text email'
}); });
``` ```
### Creating and Using Smartmail Objects ### Adding Recipients
`Smartmail` class allows you to represent an email in a structured way, including attachments, subject, body, and more. Here's how to use it: ```typescript
const email = new Smartmail({
from: 'sender@example.com',
subject: 'Meeting Invitation',
body: 'Please join our meeting'
});
// Add recipients in various ways
email.addRecipient('primary@example.com');
email.addRecipients(['user1@example.com', 'user2@example.com']);
email.addRecipient('manager@example.com', 'cc');
email.addRecipients(['observer1@example.com', 'observer2@example.com'], 'bcc');
```
### Template Variables in Subject and Body
```typescript
const template = new Smartmail({
from: 'notifications@example.com',
subject: 'Welcome, {{name}}!',
body: 'Hello {{name}},\n\nWelcome to our service. Your account ({{email}}) has been activated.',
htmlBody: '<h1>Welcome, {{name}}!</h1><p>Hello {{name}},<br><br>Welcome to our service. Your account (<strong>{{email}}</strong>) has been activated.</p>'
});
// Apply template variables
const subject = template.getSubject({ name: 'John Doe' });
const plainBody = template.getBody({ name: 'John Doe', email: 'john@example.com' });
const htmlBody = template.getHtmlBody({ name: 'John Doe', email: 'john@example.com' });
```
### Adding Attachments
```typescript ```typescript
import { Smartmail } from '@push.rocks/smartmail';
import { Smartfile } from '@push.rocks/smartfile'; import { Smartfile } from '@push.rocks/smartfile';
// Create a new Smartmail object const email = new Smartmail({
const myMail = new Smartmail({ from: 'sender@example.com',
from: 'no-reply@yourdomain.com', to: ['recipient@example.com'],
subject: 'Welcome to Our Service', subject: 'Report Attached',
body: 'Hello {{name}}, welcome to our service!' body: 'Please find the attached report.'
}); });
// Use Smartfile to prepare an attachment (optional) // Add file attachments
const attachment = new Smartfile.fromLocalPath('path/to/your/attachment.pdf'); const report = new Smartfile.fromLocalPath('/path/to/report.pdf');
myMail.addAttachment(attachment); const image = new Smartfile.fromLocalPath('/path/to/image.png');
email.addAttachment(report);
// Accessing and modifying the mail object's properties email.addAttachment(image);
console.log(myMail.getSubject({ name: 'John Doe' }));
console.log(myMail.getBody({ name: 'John Doe' }));
// The `getCreationObject` method can be used to retrieve the original creation object
console.log(myMail.getCreationObject());
``` ```
### Email Address Validation Details ### Setting Email Importance and Headers
The `EmailAddressValidator` class fetches domain information either from a local JSON file or an online source to categorize email domains as disposable, free, or valid based on MX records. Here's a deeper look into validating email addresses and interpreting the results:
```typescript ```typescript
// Instantiate the validator const email = new Smartmail({
const validator = new EmailAddressValidator(); from: 'sender@example.com',
to: ['recipient@example.com'],
// Validate an email subject: 'Urgent: System Alert',
validator.validate('someone@disposablemail.com').then(result => { body: 'Critical system alert requires immediate attention.'
if (result.valid && !result.disposable) {
console.log('Email is valid and not disposable.');
} else {
console.log('Email is either invalid or disposable.');
}
if (result.freemail) {
console.log('Email belongs to a free mail provider.');
}
}); });
// Set high priority
email.setPriority('high');
// Add custom headers
email.addHeader('X-Custom-ID', '12345');
email.addHeader('X-System-Alert', 'Critical');
``` ```
### Handling Attachments ### Converting to MIME Format for Sending
As shown in the example, attachments can be added to a `Smartmail` object. `Smartfile` (from `@push.rocks/smartfile`) is utilized to handle file operations, allowing you to attach files easily to your email object. Ensure that attachments are in the appropriate format and accessible before adding them to the `Smartmail` object. ```typescript
const email = new Smartmail({
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Hello {{name}}',
body: 'Text version: Hello {{name}}',
htmlBody: '<p>HTML version: Hello <strong>{{name}}</strong></p>',
validateEmails: true // Will validate all emails before converting
});
### Conclusion // Convert to MIME format with template data
const mimeObj = await email.toMimeFormat({ name: 'John' });
These examples cover the basics of using `@push.rocks/smartmail` for handling emails within your Node.js applications. By leveraging the `Smartmail` and `EmailAddressValidator` classes, you can efficiently represent, validate, and manage email data, enhancing the robustness and functionality of your email-related features. // Result can be used with nodemailer or other email sending libraries
console.log(mimeObj);
/*
{
from: 'sender@example.com',
to: ['recipient@example.com'],
subject: 'Hello John',
text: 'Text version: Hello John',
html: '<p>HTML version: Hello <strong>John</strong></p>',
attachments: [...],
headers: {...}
}
*/
```
## API Reference
### EmailAddressValidator
#### Constructor Options
- `skipOnlineDomainFetch`: Boolean (default: false) - Skip fetching domain list from online source
- `cacheDnsResults`: Boolean (default: true) - Cache DNS lookup results
- `cacheExpiryMs`: Number (default: 3600000) - Cache expiry in milliseconds
#### Methods
- `validate(email: string)`: Validates email address completeness
- `isValidEmailFormat(email: string)`: Checks if email format is valid
- `isValidLocalPart(localPart: string)`: Validates local part of email
- `isValidDomainPart(domainPart: string)`: Validates domain part of email
- `checkMxRecords(domain: string)`: Checks MX records for a domain
### Smartmail
#### Constructor Options
- `from`: Email address of sender
- `to`, `cc`, `bcc`: Optional arrays of recipient email addresses
- `subject`: Email subject line
- `body`: Plain text email body
- `htmlBody`: Optional HTML version of email body
- `replyTo`: Optional reply-to email address
- `headers`: Optional key-value pairs of custom headers
- `priority`: 'high' | 'normal' | 'low' (default: 'normal')
- `validateEmails`: Boolean (default: false) - Validate all emails
#### Methods
- `addRecipient(email, type?)`: Add a single recipient (to/cc/bcc)
- `addRecipients(emails, type?)`: Add multiple recipients
- `setReplyTo(email)`: Set reply-to address
- `setPriority(priority)`: Set email priority
- `addHeader(name, value)`: Add custom header
- `getSubject(data?)`: Get processed subject with template variables
- `getBody(data?)`: Get processed plain text body
- `getHtmlBody(data?)`: Get processed HTML body
- `validateAllEmails()`: Validate all email addresses
- `toMimeFormat(data?)`: Convert to MIME format object
## License and Legal Information ## License and Legal Information

92
readme.plan.md Normal file
View File

@ -0,0 +1,92 @@
# Improvement Plan for SmartMail
## Current Code Analysis
### Core Components
- **EmailAddressValidator**: Validates email addresses checking for validity, whether they are disposable or free
- Uses MX record checks to validate domains
- Has a domain map from external source that classifies domains as 'disposable' or 'freemail'
- Has placeholder 'reason' field in results that's not implemented ("todo")
- Fetches domain list from GitHub or falls back to local copy
- No validation of email format before DNS checks
- **Smartmail**: Email representation class
- Basic email structure with from, subject, body
- Supports file attachments via smartfile
- Uses mustache templates for subject and body
- Lacks recipient handling (to, cc, bcc)
- No method to send emails
- No HTML body support
### Dependencies
- Uses several @push.rocks packages:
- smartdns: For DNS lookups
- smartfile: For file handling/attachments
- smartmustache: For template processing
- smartpath: For path operations
- smartrequest: For HTTP requests
### Testing
- Basic tests for EmailAddressValidator
- Tests validation of regular, free, and disposable emails
- No tests for invalid email formats or edge cases
- Minimal test for Smartmail creation
- No tests for attachments, templates, or other features
## Enhanced Improvement Plan
1. [x] Complete the EmailAddressValidator implementation
- [x] Implement proper reason messages for validation results
- [x] Add email format validation before DNS checks (RFC 5322 compliance)
- [x] Add local part validation (check for illegal characters, proper format)
- [x] Improve domain validation (check syntax before DNS lookup)
- [x] Add email normalization (handle case sensitivity, plus addressing)
- [x] Implement caching mechanism for DNS lookups to improve performance
- [x] Add option to disable online domain list fetching
2. [x] Enhance the Smartmail class
- [x] Add support for multiple recipients (to, cc, bcc arrays)
- [x] Add email preparing capabilities via MIME format
- [x] Support HTML email bodies with plain text fallback
- [x] Add reply-to and headers support
- [x] Implement method to convert to standard email formats (MIME)
- [x] Add email priority and importance flags
- [x] Add validation of email addresses used in from/to/cc/bcc
3. [x] Improve testing
- [x] Add tests for email format validation (valid/invalid formats)
- [x] Test domain validation edge cases (non-existent domains, etc.)
- [x] Add tests for attachment handling
- [x] Test template processing with different data structures
- [x] Add tests for HTML emails and conversion
- [x] Test recipient handling with multiple addresses
4. [x] Performance & security improvements
- [x] Optimize domain list handling
- [x] Implement intelligent caching strategy for validation results
- [x] Add configuration options for external service calls
- [x] Ensure secure handling of email data and attachments
5. [x] Documentation improvements
- [x] Update README with comprehensive examples
- [x] Add detailed API documentation with JSDoc
- [x] Document all configuration options
- [x] Add usage examples for common scenarios
- [x] Document security considerations
- [x] Add TypeScript type documentation
6. [ ] Advanced features
- [ ] DKIM/SPF validation support
- [ ] Implement email address suggestions for typos
- [ ] Add disposable email detection improvements
- [ ] Support for internationalized email addresses (IDN)
- [ ] Email address reputation checking
- [ ] Add email deliverability scoring
- [ ] Implement bounce address validation
7. [x] Code quality
- [x] Add more TypeScript interfaces for clearer API definitions
- [x] Improve error handling with specific error types
- [x] Add configuration options via constructor
- [x] Make domain list updates configurable
- [x] Improve code organization with better separation of concerns

View File

@ -1,36 +1,61 @@
import { expect, tap } from '@push.rocks/tapbundle'; import { expect, tap } from '@push.rocks/tapbundle';
import * as smartmail from '../ts/index.js'; import * as smartmail from '../ts/index.js';
import * as plugins from '../ts/smartmail.plugins.js';
let emailAddressValidatorInstance: smartmail.EmailAddressValidator; let emailAddressValidatorInstance: smartmail.EmailAddressValidator;
// EmailAddressValidator Tests
tap.test('should create an instance of EmailAddressValidator', async () => { tap.test('should create an instance of EmailAddressValidator', async () => {
emailAddressValidatorInstance = new smartmail.EmailAddressValidator(); emailAddressValidatorInstance = new smartmail.EmailAddressValidator();
expect(emailAddressValidatorInstance).toBeInstanceOf(smartmail.EmailAddressValidator); expect(emailAddressValidatorInstance).toBeInstanceOf(smartmail.EmailAddressValidator);
}); });
tap.test('should validate an email', async () => { tap.test('should validate an email with detailed information', async () => {
const result = await emailAddressValidatorInstance.validate('sandbox@bleu.de'); const result = await emailAddressValidatorInstance.validate('sandbox@bleu.de');
expect(result.freemail).toBeFalse(); expect(result.freemail).toBeFalse();
expect(result.disposable).toBeFalse(); expect(result.disposable).toBeFalse();
console.log(result); expect(result.formatValid).toBeTrue();
expect(result.localPartValid).toBeTrue();
expect(result.domainPartValid).toBeTrue();
expect(result.reason).toBeDefined();
}); });
tap.test('should recognize an email as freemail', async () => { tap.test('should recognize an email as freemail', async () => {
const result = await emailAddressValidatorInstance.validate('sandbox@gmail.com'); const result = await emailAddressValidatorInstance.validate('sandbox@gmail.com');
expect(result.freemail).toBeTrue(); expect(result.freemail).toBeTrue();
expect(result.disposable).toBeFalse(); expect(result.disposable).toBeFalse();
console.log(result); expect(result.formatValid).toBeTrue();
expect(result.valid).toBeTrue();
}); });
tap.test('should recognize an email as disposable', async () => { tap.test('should recognize an email as disposable', async () => {
const result = await emailAddressValidatorInstance.validate('sandbox@gmx.de'); const result = await emailAddressValidatorInstance.validate('sandbox@gmx.de');
expect(result.freemail).toBeFalse(); expect(result.freemail).toBeFalse();
expect(result.disposable).toBeTrue(); expect(result.disposable).toBeTrue();
console.log(result); expect(result.formatValid).toBeTrue();
}); });
tap.test('should create a SmartMail', async () => { tap.test('should detect invalid email format', async () => {
const testSmartmail = new smartmail.Smartmail({ const result = await emailAddressValidatorInstance.validate('invalid-email');
expect(result.formatValid).toBeFalse();
expect(result.valid).toBeFalse();
expect(result.reason).toEqual('Invalid email format');
});
tap.test('should validate email format parts separately', async () => {
expect(emailAddressValidatorInstance.isValidEmailFormat('valid.email@example.com')).toBeTrue();
expect(emailAddressValidatorInstance.isValidEmailFormat('invalid-email')).toBeFalse();
expect(emailAddressValidatorInstance.isValidLocalPart('valid.local.part')).toBeTrue();
expect(emailAddressValidatorInstance.isValidLocalPart('invalid..part')).toBeFalse();
expect(emailAddressValidatorInstance.isValidDomainPart('example.com')).toBeTrue();
expect(emailAddressValidatorInstance.isValidDomainPart('invalid')).toBeFalse();
});
// Smartmail Tests
let testSmartmail: smartmail.Smartmail<any>;
tap.test('should create a SmartMail instance', async () => {
testSmartmail = new smartmail.Smartmail({
body: 'hi there', body: 'hi there',
from: 'noreply@mail.lossless.com', from: 'noreply@mail.lossless.com',
subject: 'hi from here', subject: 'hi from here',
@ -38,4 +63,85 @@ tap.test('should create a SmartMail', async () => {
expect(testSmartmail).toBeInstanceOf(smartmail.Smartmail); expect(testSmartmail).toBeInstanceOf(smartmail.Smartmail);
}); });
tap.test('should handle email recipients', async () => {
testSmartmail.addRecipient('user1@example.com');
testSmartmail.addRecipients(['user2@example.com', 'user3@example.com'], 'cc');
testSmartmail.addRecipient('user4@example.com', 'bcc');
expect(testSmartmail.options.to!.length).toEqual(1);
expect(testSmartmail.options.cc!.length).toEqual(2);
expect(testSmartmail.options.bcc!.length).toEqual(1);
});
tap.test('should set reply-to and priority', async () => {
testSmartmail.setReplyTo('replies@example.com');
testSmartmail.setPriority('high');
expect(testSmartmail.options.replyTo).toEqual('replies@example.com');
expect(testSmartmail.options.priority).toEqual('high');
});
tap.test('should apply template data to subject and body', async () => {
const templateMailer = new smartmail.Smartmail({
subject: 'Hello {{name}}',
body: 'Welcome, {{name}}! Your ID is {{userId}}.',
from: 'noreply@example.com'
});
const data = { name: 'John Doe', userId: '12345' };
expect(templateMailer.getSubject(data)).toEqual('Hello John Doe');
expect(templateMailer.getBody(data)).toEqual('Welcome, John Doe! Your ID is 12345.');
});
tap.test('should handle HTML email body', async () => {
const htmlMailer = new smartmail.Smartmail({
subject: 'HTML Test',
body: 'Plain text version',
htmlBody: '<h1>{{title}}</h1><p>This is an HTML email with {{variable}}.</p>',
from: 'noreply@example.com'
});
const data = { title: 'Welcome', variable: 'dynamic content' };
const htmlContent = htmlMailer.getHtmlBody(data);
expect(htmlContent).toEqual('<h1>Welcome</h1><p>This is an HTML email with dynamic content.</p>');
});
tap.test('should convert to MIME format', async () => {
const mimeMailer = new smartmail.Smartmail({
subject: 'MIME Test',
body: 'Plain text content',
htmlBody: '<p>HTML content</p>',
from: 'sender@example.com',
to: ['recipient@example.com'],
headers: { 'X-Custom': 'Value' }
});
const mimeObj = await mimeMailer.toMimeFormat();
expect(mimeObj.from).toEqual('sender@example.com');
expect(mimeObj.to).toInclude('recipient@example.com');
expect(mimeObj.subject).toEqual('MIME Test');
expect(mimeObj.text).toEqual('Plain text content');
expect(mimeObj.html).toEqual('<p>HTML content</p>');
expect(mimeObj.headers['X-Custom']).toEqual('Value');
});
tap.test('should add email headers', async () => {
const headerMailer = new smartmail.Smartmail({
subject: 'Header Test',
body: 'Test body',
from: 'noreply@example.com'
});
headerMailer.addHeader('X-Test-Header', 'TestValue');
headerMailer.addHeader('X-Tracking-ID', '12345');
const mimeObj = await headerMailer.toMimeFormat();
expect(mimeObj.headers['X-Test-Header']).toEqual('TestValue');
expect(mimeObj.headers['X-Tracking-ID']).toEqual('12345');
});
tap.start(); tap.start();

View File

@ -1,8 +1,8 @@
/** /**
* autocreated commitinfo by @pushrocks/commitinfo * autocreated commitinfo by @push.rocks/commitinfo
*/ */
export const commitinfo = { export const commitinfo = {
name: '@push.rocks/smartmail', name: '@push.rocks/smartmail',
version: '1.0.24', version: '2.0.0',
description: 'a unified format for representing and dealing with mails' description: 'A unified format for representing and dealing with emails, with support for attachments and email validation.'
} }

View File

@ -6,27 +6,190 @@ export interface IEmailValidationResult {
disposable: boolean; disposable: boolean;
freemail: boolean; freemail: boolean;
reason: string; reason: string;
formatValid: boolean;
mxValid: boolean;
localPartValid: boolean;
domainPartValid: boolean;
}
export interface IEmailAddressValidatorOptions {
skipOnlineDomainFetch?: boolean;
cacheDnsResults?: boolean;
cacheExpiryMs?: number;
} }
export class EmailAddressValidator { export class EmailAddressValidator {
public domainMap: { [key: string]: 'disposable' | 'freemail' }; public domainMap: { [key: string]: 'disposable' | 'freemail' };
public smartdns = new plugins.smartdns.Smartdns({}); public smartdns = new plugins.smartdns.Smartdns({});
private dnsCache: Map<string, { result: any; timestamp: number }> = new Map();
private options: IEmailAddressValidatorOptions;
public async validate(emailArg: string): Promise<IEmailValidationResult> { constructor(optionsArg: IEmailAddressValidatorOptions = {}) {
await this.fetchDomains(); this.options = {
const emailArray = emailArg.split('@'); skipOnlineDomainFetch: false,
const result = await this.smartdns.getRecords(emailArray[1], 'MX'); cacheDnsResults: true,
// console.log(emailArray); cacheExpiryMs: 3600000, // 1 hour
// console.log(this.domainMap[emailArray[1]]); ...optionsArg
return {
valid: !!result,
reason: 'todo',
disposable: this.domainMap[emailArray[1]] === 'disposable',
freemail: this.domainMap[emailArray[1]] === 'freemail',
}; };
} }
/**
* Validates an email address format according to RFC 5322
* @param emailArg The email address to validate
* @returns True if the format is valid
*/
public isValidEmailFormat(emailArg: string): boolean {
if (!emailArg) return false;
// RFC 5322 compliant regex pattern
const emailRegex = /^(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
return emailRegex.test(emailArg);
}
/**
* Validates the local part of an email address (before the @)
* @param localPart The local part of the email address
* @returns True if the local part is valid
*/
public isValidLocalPart(localPart: string): boolean {
if (!localPart) return false;
if (localPart.length > 64) return false;
// Check for illegal characters and patterns
const illegalChars = /[^\w.!#$%&'*+/=?^`{|}~-]/;
if (illegalChars.test(localPart)) return false;
// Check for consecutive dots or leading/trailing dots
if (localPart.includes('..') || localPart.startsWith('.') || localPart.endsWith('.')) return false;
return true;
}
/**
* Validates the domain part of an email address (after the @)
* @param domainPart The domain part of the email address
* @returns True if the domain part is valid
*/
public isValidDomainPart(domainPart: string): boolean {
if (!domainPart) return false;
if (domainPart.length > 255) return false;
// Domain name validation regex
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
// Must have at least one dot
if (!domainPart.includes('.')) return false;
// Must end with a valid TLD (at least 2 chars)
const parts = domainPart.split('.');
const tld = parts[parts.length - 1];
if (tld.length < 2) return false;
return domainRegex.test(domainPart);
}
/**
* Performs DNS MX record lookup for a domain
* @param domain The domain to check
* @returns MX records or null if none exist
*/
public async checkMxRecords(domain: string): Promise<any> {
if (this.options.cacheDnsResults) {
const cached = this.dnsCache.get(domain);
if (cached && (Date.now() - cached.timestamp) < this.options.cacheExpiryMs!) {
return cached.result;
}
}
const result = await this.smartdns.getRecords(domain, 'MX');
if (this.options.cacheDnsResults) {
this.dnsCache.set(domain, { result, timestamp: Date.now() });
}
return result;
}
/**
* Validates an email address
* @param emailArg The email address to validate
* @returns Validation result with details
*/
public async validate(emailArg: string): Promise<IEmailValidationResult> {
await this.fetchDomains();
// Initialize result
const result: IEmailValidationResult = {
valid: false,
reason: '',
disposable: false,
freemail: false,
formatValid: false,
mxValid: false,
localPartValid: false,
domainPartValid: false
};
// Check overall email format
const formatValid = this.isValidEmailFormat(emailArg);
result.formatValid = formatValid;
if (!formatValid) {
result.reason = 'Invalid email format';
return result;
}
// Split email into local and domain parts
const [localPart, domainPart] = emailArg.split('@');
// Validate local part
const localPartValid = this.isValidLocalPart(localPart);
result.localPartValid = localPartValid;
if (!localPartValid) {
result.reason = 'Invalid local part (username)';
return result;
}
// Validate domain part
const domainPartValid = this.isValidDomainPart(domainPart);
result.domainPartValid = domainPartValid;
if (!domainPartValid) {
result.reason = 'Invalid domain part';
return result;
}
// Check MX records
const mxRecords = await this.checkMxRecords(domainPart);
result.mxValid = !!mxRecords;
if (!mxRecords) {
result.reason = 'Domain does not have valid MX records';
return result;
}
// Check if domain is disposable or free
result.disposable = this.domainMap[domainPart] === 'disposable';
result.freemail = this.domainMap[domainPart] === 'freemail';
if (result.disposable) {
result.reason = 'Domain is a disposable email provider';
} else if (result.freemail) {
result.reason = 'Domain is a free email provider';
} else {
result.reason = 'Email is valid';
}
// Email is valid if it has proper format and MX records
result.valid = result.formatValid && result.mxValid;
return result;
}
/**
* Fetches the domain list for checking disposable and free email providers
*/
public async fetchDomains() { public async fetchDomains() {
if (!this.domainMap) { if (!this.domainMap) {
const localFileString = plugins.smartfile.fs.toStringSync( const localFileString = plugins.smartfile.fs.toStringSync(
@ -34,21 +197,20 @@ export class EmailAddressValidator {
); );
const localFileObject = JSON.parse(localFileString); const localFileObject = JSON.parse(localFileString);
let onlineFileObject: any; if (this.options.skipOnlineDomainFetch) {
this.domainMap = localFileObject;
return;
}
try { try {
onlineFileObject = ( const onlineFileObject = (
await plugins.smartrequest.getJson( await plugins.smartrequest.getJson(
'https://raw.githubusercontent.com/romainsimon/emailvalid/master/domains.json' 'https://raw.githubusercontent.com/romainsimon/emailvalid/master/domains.json'
) )
).body; ).body;
this.domainMap = onlineFileObject; this.domainMap = onlineFileObject;
console.log(
'smartmail EmailAddressValidator: Using online email list for email validation'
);
} catch (e) { } catch (e) {
this.domainMap = localFileObject; this.domainMap = localFileObject;
console.log(e);
console.log('smartmail EmailAddressValidator: Using local email list for email validation');
} }
} }
} }

View File

@ -1,38 +1,257 @@
import * as plugins from './smartmail.plugins.js'; import * as plugins from './smartmail.plugins.js';
import { EmailAddressValidator } from './smartmail.classes.emailaddressvalidator.js';
export type EmailAddress = string;
export type EmailAddressList = EmailAddress[];
export interface ISmartmailOptions<T> { export interface ISmartmailOptions<T> {
from: string; from: EmailAddress;
to?: EmailAddressList;
cc?: EmailAddressList;
bcc?: EmailAddressList;
replyTo?: EmailAddress;
subject: string; subject: string;
body: string; body: string;
htmlBody?: string;
creationObjectRef?: T; creationObjectRef?: T;
headers?: Record<string, string>;
priority?: 'high' | 'normal' | 'low';
validateEmails?: boolean;
}
export interface IMimeAttachment {
filename: string;
content: Buffer;
contentType: string;
} }
/** /**
* a standard representation for mails * A standard representation for emails with advanced features
*/ */
export class Smartmail<T> { export class Smartmail<T> {
public options: ISmartmailOptions<T>; public options: ISmartmailOptions<T>;
public attachments: plugins.smartfile.Smartfile[] = []; public attachments: plugins.smartfile.SmartFile[] = [];
private emailValidator: EmailAddressValidator;
constructor(optionsArg: ISmartmailOptions<T>) { constructor(optionsArg: ISmartmailOptions<T>) {
this.options = optionsArg; // Set default options
this.options = {
validateEmails: false,
to: [],
cc: [],
bcc: [],
headers: {},
priority: 'normal',
...optionsArg
};
this.emailValidator = new EmailAddressValidator();
} }
public addAttachment(smartfileArg: plugins.smartfile.Smartfile) { /**
* Adds an attachment to the email
* @param smartfileArg The file to attach
*/
public addAttachment(smartfileArg: plugins.smartfile.SmartFile) {
this.attachments.push(smartfileArg); this.attachments.push(smartfileArg);
} }
/**
* Gets the creation object reference
* @returns The creation object reference
*/
public getCreationObject(): T { public getCreationObject(): T {
return this.options.creationObjectRef; return this.options.creationObjectRef;
} }
public getSubject(dataArg: any = {}) { /**
* Gets the processed subject with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed subject
*/
public getSubject(dataArg: any = {}): string {
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.subject); const smartmustache = new plugins.smartmustache.SmartMustache(this.options.subject);
return smartmustache.applyData(dataArg); return smartmustache.applyData(dataArg);
} }
public getBody(dataArg: any = {}) { /**
* Gets the processed plain text body with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed body
*/
public getBody(dataArg: any = {}): string {
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.body); const smartmustache = new plugins.smartmustache.SmartMustache(this.options.body);
return smartmustache.applyData(dataArg); return smartmustache.applyData(dataArg);
} }
/**
* Gets the processed HTML body with template variables applied
* @param dataArg Data to apply to the template
* @returns Processed HTML body or null if not set
*/
public getHtmlBody(dataArg: any = {}): string | null {
if (!this.options.htmlBody) {
return null;
}
const smartmustache = new plugins.smartmustache.SmartMustache(this.options.htmlBody);
return smartmustache.applyData(dataArg);
}
/**
* Adds a recipient to the email
* @param email Email address to add
* @param type Type of recipient (to, cc, bcc)
*/
public addRecipient(email: EmailAddress, type: 'to' | 'cc' | 'bcc' = 'to'): void {
if (!this.options[type]) {
this.options[type] = [];
}
this.options[type]!.push(email);
}
/**
* Adds multiple recipients to the email
* @param emails Email addresses to add
* @param type Type of recipients (to, cc, bcc)
*/
public addRecipients(emails: EmailAddressList, type: 'to' | 'cc' | 'bcc' = 'to'): void {
if (!this.options[type]) {
this.options[type] = [];
}
this.options[type] = [...this.options[type]!, ...emails];
}
/**
* Sets the reply-to address
* @param email Email address for reply-to
*/
public setReplyTo(email: EmailAddress): void {
this.options.replyTo = email;
}
/**
* Sets the priority of the email
* @param priority Priority level
*/
public setPriority(priority: 'high' | 'normal' | 'low'): void {
this.options.priority = priority;
}
/**
* Adds a custom header to the email
* @param name Header name
* @param value Header value
*/
public addHeader(name: string, value: string): void {
if (!this.options.headers) {
this.options.headers = {};
}
this.options.headers[name] = value;
}
/**
* Validates all email addresses in the email
* @returns Promise resolving to validation results
*/
public async validateAllEmails(): Promise<Record<string, boolean>> {
const results: Record<string, boolean> = {};
const emails: EmailAddress[] = [];
// Collect all emails
if (this.options.from) emails.push(this.options.from);
if (this.options.replyTo) emails.push(this.options.replyTo);
if (this.options.to) emails.push(...this.options.to);
if (this.options.cc) emails.push(...this.options.cc);
if (this.options.bcc) emails.push(...this.options.bcc);
// Validate each email
for (const email of emails) {
const validationResult = await this.emailValidator.validate(email);
results[email] = validationResult.valid;
}
return results;
}
/**
* Converts the email to a MIME format object for sending
* @param dataArg Data to apply to templates
* @returns MIME format object
*/
public async toMimeFormat(dataArg: any = {}): Promise<any> {
// Validate emails if option is enabled
if (this.options.validateEmails) {
const validationResults = await this.validateAllEmails();
const invalidEmails = Object.entries(validationResults)
.filter(([_, valid]) => !valid)
.map(([email]) => email);
if (invalidEmails.length > 0) {
throw new Error(`Invalid email addresses: ${invalidEmails.join(', ')}`);
}
}
// Build MIME parts
const subject = this.getSubject(dataArg);
const textBody = this.getBody(dataArg);
const htmlBody = this.getHtmlBody(dataArg);
// Convert attachments to MIME format
const mimeAttachments: IMimeAttachment[] = await Promise.all(
this.attachments.map(async (file) => {
return {
filename: file.path.split('/').pop()!,
content: file.contentBuffer,
contentType: 'application/octet-stream'
};
})
);
// Build email format object
const mimeObj: any = {
from: this.options.from,
subject,
text: textBody,
attachments: mimeAttachments,
headers: { ...this.options.headers }
};
// Add optional fields
if (this.options.to && this.options.to.length > 0) {
mimeObj.to = this.options.to;
}
if (this.options.cc && this.options.cc.length > 0) {
mimeObj.cc = this.options.cc;
}
if (this.options.bcc && this.options.bcc.length > 0) {
mimeObj.bcc = this.options.bcc;
}
if (this.options.replyTo) {
mimeObj.replyTo = this.options.replyTo;
}
if (htmlBody) {
mimeObj.html = htmlBody;
}
// Add priority headers if specified
if (this.options.priority === 'high') {
mimeObj.headers['X-Priority'] = '1';
mimeObj.headers['X-MSMail-Priority'] = 'High';
mimeObj.headers['Importance'] = 'High';
} else if (this.options.priority === 'low') {
mimeObj.headers['X-Priority'] = '5';
mimeObj.headers['X-MSMail-Priority'] = 'Low';
mimeObj.headers['Importance'] = 'Low';
}
return mimeObj;
}
} }

View File

@ -4,7 +4,7 @@ import * as path from 'path';
export { path }; export { path };
// pushrocks scope // pushrocks scope
import * as smartdns from '@push.rocks/smartdns'; import * as smartdns from '@push.rocks/smartdns/client';
import * as smartfile from '@push.rocks/smartfile'; import * as smartfile from '@push.rocks/smartfile';
import * as smartmustache from '@push.rocks/smartmustache'; import * as smartmustache from '@push.rocks/smartmustache';
import * as smartpath from '@push.rocks/smartpath'; import * as smartpath from '@push.rocks/smartpath';

View File

@ -6,9 +6,22 @@
"module": "NodeNext", "module": "NodeNext",
"moduleResolution": "NodeNext", "moduleResolution": "NodeNext",
"esModuleInterop": true, "esModuleInterop": true,
"verbatimModuleSyntax": true "verbatimModuleSyntax": true,
"declaration": true,
"sourceMap": true,
"outDir": "./dist_ts",
"rootDir": "./ts",
"strict": false,
"lib": ["ES2022", "DOM"],
"skipLibCheck": false
}, },
"include": [
"ts/**/*"
],
"exclude": [ "exclude": [
"dist_*/**/*.d.ts" "node_modules",
"dist",
"dist_ts",
"**/*.spec.ts"
] ]
} }