Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@weaponsforge/sendemail",
"version": "2.0.0",
"version": "2.0.1",
"description": "Sends emails using Gmail SMTP with username/pw or Google OAuth2",
"main": "dist/index.js",
"types": "./dist/index.d.ts",
Expand Down
83 changes: 83 additions & 0 deletions app/src/__tests__/build.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import path from 'path'
import { beforeAll, describe, expect, it } from 'vitest'

import { buildHtml } from '@/lib/email/build.js'
import { directory } from '@/utils/helpers.js'
import { EmailSchemaMessages } from '@/types/email.schema.js'

// buildHtml resolves the EJS template relative to __dirname (set to the build.ts location)
beforeAll(() => {
globalThis.__dirname = path.resolve(directory(import.meta.url), '..', 'lib', 'email')
})

const TEST_RECIPIENTS = ['tester@gmail.com']
const TEST_SENDER = 'sender@gmail.com'

describe('buildHtml test', () => {
it('should return an HTML string from paragraph content', async () => {
const html = await buildHtml({
content: ['Hello, World!', 'Second paragraph'],
recipients: TEST_RECIPIENTS,
sender: TEST_SENDER,
})

expect(typeof html).toBe('string')
expect(html.length).toBeGreaterThan(0)
})

it('should return an HTML string from wysiwyg content', async () => {
const html = await buildHtml({
content: [],
recipients: TEST_RECIPIENTS,
sender: TEST_SENDER,
wysiwyg: '<p>Hello, wysiwyg!</p>',
})

expect(typeof html).toBe('string')
expect(html).toContain('Hello, wysiwyg!')
})

it('should strip script tags from wysiwyg content', async () => {
const html = await buildHtml({
content: [],
recipients: TEST_RECIPIENTS,
sender: TEST_SENDER,
wysiwyg: '<script>alert("xss")</script><p>Safe content</p>',
})

expect(html).not.toContain('<script>')
expect(html).not.toContain('alert("xss")')
})

it('should reject if both content and wysiwyg are missing', async () => {
await expect(
buildHtml({
content: [],
recipients: TEST_RECIPIENTS,
sender: TEST_SENDER,
}),
).rejects.toThrow()
})

it('should reject with an invalid sender email', async () => {
await expect(
buildHtml({
content: ['Hello'],
recipients: TEST_RECIPIENTS,
sender: 'not-an-email',
}),
).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL)
})

it('should reject if recipients list exceeds 20', async () => {
const tooManyRecipients = Array.from({ length: 21 }, (_, i) => `user${i}@gmail.com`)

await expect(
buildHtml({
content: ['Hello'],
recipients: tooManyRecipients,
sender: TEST_SENDER,
}),
).rejects.toThrow(EmailSchemaMessages.RECIPIENT_EMAIL_MAX)
})
})
2 changes: 1 addition & 1 deletion app/src/__tests__/send.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe('Send email test', () => {
googleUserEmail: process.env.GOOGLE_USER_EMAIL || '',
googleClientId: process.env.GOOGLE_CLIENT_ID || '',
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
googleRereshToken: process.env.GOOGLE_REFRESH_TOKEN || '',
googleRefreshToken: process.env.GOOGLE_REFRESH_TOKEN || '',
}

await expect(
Expand Down
57 changes: 57 additions & 0 deletions app/src/__tests__/transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import EmailTransport from '@/lib/email/transport.js'

describe('EmailTransport test', () => {
it('should initialize with a null transporter', () => {
const transport = new EmailTransport()
expect(transport.transporter).toBeNull()
})

it('should throw when getTransportOptions is called before createTransport3LO', () => {
const transport = new EmailTransport()
expect(() => transport.getTransportOptions()).toThrow('Transport not initialized')
})

it('should throw when googleUserEmail is not a valid email address', async () => {
const transport = new EmailTransport()

await expect(
transport.createTransport3LO({
googleUserEmail: 'not-an-email',
googleClientId: 'some-client-id',
googleClientSecret: 'some-secret',
googleRefreshToken: 'some-refresh-token',
}),
).rejects.toThrow()
})

it('should have a non-null transporter after createTransport3LO with valid credentials', async () => {
const transport = new EmailTransport()

await transport.createTransport3LO({
googleUserEmail: process.env.GOOGLE_USER_EMAIL || 'user@gmail.com',
googleClientId: process.env.GOOGLE_CLIENT_ID || 'client-id',
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client-secret',
googleRefreshToken: process.env.GOOGLE_REFRESH_TOKEN || 'refresh-token',
})

expect(transport.transporter).not.toBeNull()
})

it('should expose transport options after createTransport3LO', async () => {
const transport = new EmailTransport()

await transport.createTransport3LO({
googleUserEmail: process.env.GOOGLE_USER_EMAIL || 'user@gmail.com',
googleClientId: process.env.GOOGLE_CLIENT_ID || 'client-id',
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || 'client-secret',
googleRefreshToken: process.env.GOOGLE_REFRESH_TOKEN || 'refresh-token',
})

const options = transport.getTransportOptions()
expect(options).toBeDefined()
expect(options.host).toBe('smtp.gmail.com')
expect(options.port).toBe(465)
expect(options.secure).toBe(true)
})
})
12 changes: 11 additions & 1 deletion app/src/lib/email/sender.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,14 @@ class EmailSender extends EmailTransport implements IEmailSender {
throw new Error(EmailSchemaMessages.RECIPIENT_EMAIL_MAX)
}

const from = transportOptions.auth?.user || process.env.GOOGLE_USER_EMAIL

if (!from) {
throw new Error(TransportMessages.MISSING_SENDER_EMAIL)
}

return await this.transporter!.sendMail({
from: transportOptions.auth?.user || process.env.GOOGLE_USER_EMAIL,
from,
to: receivers,
subject,
...(!isHtml && { text: content }), // Text email
Expand All @@ -61,4 +67,8 @@ class EmailSender extends EmailTransport implements IEmailSender {
}
}

const TransportMessages: Record<string, string> = {
MISSING_SENDER_EMAIL: 'Sender email address is not set. Ensure GOOGLE_USER_EMAIL is defined or pass it via oauth2 options.',
}

export default EmailSender
4 changes: 2 additions & 2 deletions app/src/lib/email/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ class EmailTransport implements IEmailTransport {
googleUserEmail: options?.googleUserEmail || process.env.GOOGLE_USER_EMAIL,
googleClientId: options?.googleClientId || process.env.GOOGLE_CLIENT_ID,
googleClientSecret: options?.googleClientSecret || process.env.GOOGLE_CLIENT_SECRET,
googleRereshToken: options?.googleRereshToken || process.env.GOOGLE_REFRESH_TOKEN,
googleRefreshToken: options?.googleRefreshToken || process.env.GOOGLE_REFRESH_TOKEN,
}

this.#schema?.validate({ data: inputData })
Expand All @@ -53,7 +53,7 @@ class EmailTransport implements IEmailTransport {
user: inputData.googleUserEmail,
clientId: inputData.googleClientId,
clientSecret: inputData.googleClientSecret,
refreshToken: inputData.googleRereshToken,
refreshToken: inputData.googleRefreshToken,
},
})
} catch (err: unknown) {
Expand Down
63 changes: 28 additions & 35 deletions app/src/scripts/cli/lib/handleHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,35 @@ export const handleSendHtmlEmail = async (options: EmailHtmlOptions) => {
throw new Error('GOOGLE_USER_EMAIL .env variable is required')
}

try {
const {
subject,
content: paragraphs = [],
recipients = [],
wysiwyg = null,
} = options

if (paragraphs.length === 0 && typeof wysiwyg !== 'string') {
throw new Error('One of content or wysiwyg is required')
}

// Clean data of whitespace
const emails = recipients.map(email => email.trim())

console.log(`Sending email to (${emails.length}) recipients`)

const emailContent = await buildHtml({
content: paragraphs,
recipients: emails,
sender: process.env.GOOGLE_USER_EMAIL,
wysiwyg,
})

await send({
subject,
content: emailContent,
recipients: emails,
isHtml: true,
})
} catch (err: unknown) {
if (err instanceof Error) {
console.log('[ERROR]: handle HTML email')
throw err
}
const {
subject,
content: paragraphs = [],
recipients = [],
wysiwyg = null,
} = options

if (paragraphs.length === 0 && typeof wysiwyg !== 'string') {
throw new Error('One of content or wysiwyg is required')
}

// Clean data of whitespace
const emails = recipients.map(email => email.trim())

console.log(`Sending email to (${emails.length}) recipients`)

const emailContent = await buildHtml({
content: paragraphs,
recipients: emails,
sender: process.env.GOOGLE_USER_EMAIL,
wysiwyg,
})

await send({
subject,
content: emailContent,
recipients: emails,
isHtml: true,
})

console.log('Process success')
}
4 changes: 2 additions & 2 deletions app/src/types/email.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ interface IOptionalParams {
* Base schema for email validation within the `EmailTransport.sendEmail()` method
* @property {string} [recipient] - (Optional) Email address of a recipient that will receive an email. Required if `recipients[]` is undefined.
* @property {string[]} [recipients] - (Optional) Array of one or more email addresses of recipients that will receive an email. Required if `recipient` is undefined.
* @property {string} subject Email message title (max 100 characters)
* @property {string} subject Email message title (max 200 characters)
* @property {string} content Email message content can be a simple text or HTML string (max 1500 characters)
*/
export const BaseEmailSchema = z.object({
Expand Down Expand Up @@ -57,7 +57,7 @@ export const EmailSchema = BaseEmailSchema.refine(
* @typedef {object} EmailSenderType
* @property {string} [recipient] - (Optional) Email address of a recipient that will receive an email. Required if `recipients[]` is undefined.
* @property {string[]} [recipients] - (Optional) One (1) or more comma-separated email addresses of recipients that will receive an email. Required if `recipient` is undefined.
* @property {string} subject - Email message title (max 100 characters)
* @property {string} subject - Email message title (max 200 characters)
* @property {string} content - Email message content can can be a simple text or HTML string (max 1500 characters)
* @property {boolean} isHtml - Flag indicating if the `content` field is in HTML format. Defaults to `false`.
*/
Expand Down
4 changes: 2 additions & 2 deletions app/src/types/schemavalidator.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,9 +61,9 @@ export interface ISchemaValidator {
/**
* @description Get the base `ZodObject` schema from a `ZodEffects` schema
* @param {ZodSchemaType} schema - The `ZodEffects` schema to get the `ZodObject` base schema from
* @returns {ZodObjectBasicType | null} The base schema or null if it's not a ZodEffects
* @returns {ZodObjectBasicType} The base schema
*/
getBaseSchema (schema: ZodSchemaType): ZodObjectBasicType | null;
getBaseSchema (schema: ZodSchemaType): ZodObjectBasicType;

/**
* Retrieves only the base `ZodObject` subset from `this.schema` using `.pick()`. Finds the base `ZodObject` if `this.schema` is a `ZodEffects` schema.
Expand Down
5 changes: 3 additions & 2 deletions app/src/types/sender.interface.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { EmailType } from '@/types/email.schema.js'
import type { SentMessageInfo } from '@/types/transport.types.js'
import SchemaValidator from '@/lib/validator/schemavalidator.js'

export interface IEmailSender {
/**
* Sends an email using Gmail SMTP and Google OAuth2
* @param {EmailType} params Input parameters for sending email
* @returns {Promise<void>} Resolved Promise that sent the email
* @returns {Promise<SentMessageInfo>} Resolved Promise with Nodemailer send result
* @throws {ZodIssue[]} Input parameter validation error/s
*/
sendEmail (params: EmailType): Promise<void>;
sendEmail (params: EmailType): Promise<SentMessageInfo>;

/**
* Retrieves the email sender's local zod schema wrapper
Expand Down
2 changes: 1 addition & 1 deletion app/src/types/transport.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const TransportOath2Schema = z.object({
googleUserEmail: z.string().email().max(150),
googleClientId: z.string().max(200),
googleClientSecret: z.string().max(200),
googleRereshToken: z.string().max(500),
googleRefreshToken: z.string().max(500),
})

export type TransportOath2SchemaType = z.infer<typeof TransportOath2Schema>;
Loading