diff --git a/services/mail/pod-mail/README.md b/services/mail/pod-mail/README.md index c2290dd64d..246b9bd49c 100644 --- a/services/mail/pod-mail/README.md +++ b/services/mail/pod-mail/README.md @@ -53,6 +53,32 @@ Send an email message. - `subject`: Required. String containing the email subject. - `html`: Optional. String containing HTML message body. - `from`: Optional. Sender's email address. +- `attachments`: Optional. Array of objects, each object can have the following fields: + - `filename`: Filename to be reported as the name of the attached file. Use of unicode is allowed. + - `contentType`: Optional. Content type for the attachment, if not set will be derived from the filename property. + - `content`: String, Buffer, or a Stream contents for the attachment. + - `href`: Optional. An URL to the file (data URIs are allowed as well). + - `contentDisposition`: Optional. Content disposition type for the attachment, defaults to ‘attachment’. + - `cid`: Optional. Content id for using inline images in HTML message source. + - `encoding`: Optional. If set and content is a string, then encodes the content to a Buffer using the specified encoding. Example values: ‘base64’, ‘hex’, ‘binary’ etc. Useful if you want to use binary attachments in a JSON formatted email object. + - `raw`: An optional special value that overrides the entire contents of the current MIME node, including MIME headers. Useful if you want to prepare node contents yourself. + +Request body example: +``` +{ + "subject": "Test SMTP", + "text": "My text", + "from": "test1@example.com", + "to": "test2@example.com", + "attachments": [ + { + "filename": "test.txt", + "content": "Hello world", + "contentType": "text/plain" + } + ] +} +``` #### Response diff --git a/services/mail/pod-mail/src/__tests__/main.test.ts b/services/mail/pod-mail/src/__tests__/main.test.ts new file mode 100644 index 0000000000..9020308b36 --- /dev/null +++ b/services/mail/pod-mail/src/__tests__/main.test.ts @@ -0,0 +1,87 @@ +// +// Copyright © 2025 Hardcore Engineering Inc. +// +// Licensed under the Eclipse Public License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. You may +// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import { Request, Response } from 'express' +import { MailClient } from '../mail' +import { handleSendMail } from '../main' + +jest.mock('../mail', () => ({ + MailClient: jest.fn().mockImplementation(() => ({ + sendMessage: jest.fn() + })) +})) +jest.mock('../config', () => ({})) + +describe('handleSendMail', () => { + let req: Request + let res: Response + let sendMailMock: jest.Mock + + beforeEach(() => { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + req = { + body: { + text: 'Hello, world!', + subject: 'Test Subject', + to: 'test@example.com' + } + } as Request + + res = { + status: jest.fn().mockReturnThis(), + send: jest.fn() + } as unknown as Response + + sendMailMock = (new MailClient().sendMessage as jest.Mock).mockResolvedValue({}) + }) + + it('should return 400 if text is missing', async () => { + req.body.text = undefined + + await handleSendMail(new MailClient(), req, res) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(res.status).toHaveBeenCalledWith(400) + expect(res.send).toHaveBeenCalledWith({ err: "'text' is missing" }) + }) + + it('should return 400 if subject is missing', async () => { + req.body.subject = undefined + + await handleSendMail(new MailClient(), req, res) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(res.status).toHaveBeenCalledWith(400) + expect(res.send).toHaveBeenCalledWith({ err: "'subject' is missing" }) + }) + + it('should return 400 if to is missing', async () => { + req.body.to = undefined + + await handleSendMail(new MailClient(), req, res) + + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(res.status).toHaveBeenCalledWith(400) + expect(res.send).toHaveBeenCalledWith({ err: "'to' is missing" }) + }) + + it('handles errors thrown by MailClient', async () => { + sendMailMock.mockRejectedValue(new Error('Email service error')) + + await handleSendMail(new MailClient(), req, res) + + expect(res.send).toHaveBeenCalled() // Check that a response is still sent + }) +}) diff --git a/services/mail/pod-mail/src/mail.ts b/services/mail/pod-mail/src/mail.ts index c1dc6fc1b0..36bc581020 100644 --- a/services/mail/pod-mail/src/mail.ts +++ b/services/mail/pod-mail/src/mail.ts @@ -15,7 +15,6 @@ import { type SendMailOptions, type Transporter } from 'nodemailer' import config from './config' -import { Message, Receivers } from './types' import { getTransport } from './transport' export class MailClient { @@ -25,24 +24,8 @@ export class MailClient { this.transporter = getTransport(config) } - async sendMessage (message: Message, receivers: Receivers, from?: string): Promise { - const mailOptions: SendMailOptions = { - from: from ?? config.source, - to: receivers.to, - text: message.text, - subject: message.subject - } - if (receivers.cc !== undefined) { - mailOptions.cc = receivers.cc - } - if (receivers.bcc !== undefined) { - mailOptions.bcc = receivers.bcc - } - if (message.html !== undefined) { - mailOptions.html = message.html - } - - this.transporter.sendMail(mailOptions, (err, info) => { + async sendMessage (message: SendMailOptions): Promise { + this.transporter.sendMail(message, (err, info) => { if (err !== null) { console.error('Failed to send email: ', err.message) } else { diff --git a/services/mail/pod-mail/src/main.ts b/services/mail/pod-mail/src/main.ts index 0d673beaba..306b29b3b7 100644 --- a/services/mail/pod-mail/src/main.ts +++ b/services/mail/pod-mail/src/main.ts @@ -13,6 +13,9 @@ // limitations under the License. // +import { type SendMailOptions } from 'nodemailer' +import { Request, Response } from 'express' + import config from './config' import { createServer, listen } from './server' import { MailClient } from './mail' @@ -27,34 +30,7 @@ export const main = async (): Promise => { endpoint: '/send', type: 'post', handler: async (req, res) => { - // Skip auth check, since service should be internal - const text = req.body?.text - if (text === undefined) { - res.status(400).send({ err: "'text' is missing" }) - return - } - const subject = req.body?.subject - if (subject === undefined) { - res.status(400).send({ err: "'subject' is missing" }) - return - } - const html = req.body?.html - const to = req.body?.to - if (to === undefined) { - res.status(400).send({ err: "'to' is missing" }) - return - } - const receivers = { - to: Array.isArray(to) ? to : [to] - } - const from = req.body?.from - try { - await client.sendMessage({ text, subject, html }, receivers, from) - } catch (err) { - console.log(err) - } - - res.send() + await handleSendMail(client, req, res) } } ] @@ -76,3 +52,27 @@ export const main = async (): Promise => { console.error(e) }) } + +export async function handleSendMail (client: MailClient, req: Request, res: Response): Promise { + // Skip auth check, since service should be internal + const message: SendMailOptions = req.body + if (message?.text === undefined) { + res.status(400).send({ err: "'text' is missing" }) + return + } + if (message?.subject === undefined) { + res.status(400).send({ err: "'subject' is missing" }) + return + } + if (message?.to === undefined) { + res.status(400).send({ err: "'to' is missing" }) + return + } + try { + await client.sendMessage(message) + } catch (err) { + console.log(err) + } + + res.send() +} diff --git a/services/mail/pod-mail/src/types.ts b/services/mail/pod-mail/src/types.ts index c9d2ace095..e47be22f1a 100644 --- a/services/mail/pod-mail/src/types.ts +++ b/services/mail/pod-mail/src/types.ts @@ -15,18 +15,6 @@ import { NextFunction, Request, Response } from 'express' -export interface Receivers { - to: string[] - cc?: string[] - bcc?: string[] -} - -export interface Message { - text: string - subject: string - html?: string -} - export type RequestType = 'get' | 'post' export type RequestHandler = (req: Request, res: Response, next?: NextFunction) => Promise