UBERF-9557: Support attachments in mail service (#8139)

* UBERF-9557: Support attachments in mail service

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-9557: Fix formatting

Signed-off-by: Artem Savchenko <armisav@gmail.com>

* UBERF-9557: Fix tests

Signed-off-by: Artem Savchenko <armisav@gmail.com>

---------

Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Artyom Savchenko
2025-03-05 13:26:33 +07:00
committed by GitHub
parent f3c231df0a
commit 6a37cf8d07
5 changed files with 143 additions and 59 deletions
+26
View File
@@ -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
@@ -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
})
})
+2 -19
View File
@@ -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<void> {
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<void> {
this.transporter.sendMail(message, (err, info) => {
if (err !== null) {
console.error('Failed to send email: ', err.message)
} else {
+28 -28
View File
@@ -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<void> => {
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<void> => {
console.error(e)
})
}
export async function handleSendMail (client: MailClient, req: Request, res: Response): Promise<void> {
// 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()
}
-12
View File
@@ -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<void>