From 561039d987007c37e341084610ac51631b44aca2 Mon Sep 17 00:00:00 2001 From: Elad Abutbul Date: Wed, 3 Jun 2026 20:26:12 +0300 Subject: [PATCH 1/2] implement email service with zod and nodemailer --- package.json | 5 +- pnpm-lock.yaml | 27 +++++ src/app.ts | 2 + src/constants/email/email.constants.ts | 56 ++++++++++ src/controllers/email/email.controller.ts | 26 +++++ src/routes/email.routes.ts | 99 ++++++++++++++++ src/services/email/email.service.helpers.ts | 118 ++++++++++++++++++++ src/services/email/email.service.ts | 66 +++++++++++ 8 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 src/constants/email/email.constants.ts create mode 100644 src/controllers/email/email.controller.ts create mode 100644 src/routes/email.routes.ts create mode 100644 src/services/email/email.service.helpers.ts create mode 100644 src/services/email/email.service.ts diff --git a/package.json b/package.json index 68acd94..9fd8351 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@eslint/js": "^10.0.1", "@types/express": "^5.0.6", "@types/node": "^25.5.0", + "@types/nodemailer": "^8.0.0", "@types/swagger-jsdoc": "^6.0.4", "@types/swagger-ui-express": "^4.1.8", "eslint": "^10.0.3", @@ -40,7 +41,9 @@ "express": "^5.2.1", "http-status-codes": "^2.3.0", "mongoose": "^9.4.1", + "nodemailer": "^8.0.10", "swagger-jsdoc": "^6.2.8", - "swagger-ui-express": "^5.0.1" + "swagger-ui-express": "^5.0.1", + "zod": "^4.4.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb4c269..3676ad0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,12 +20,18 @@ importers: mongoose: specifier: ^9.4.1 version: 9.4.1 + nodemailer: + specifier: ^8.0.10 + version: 8.0.10 swagger-jsdoc: specifier: ^6.2.8 version: 6.2.8(openapi-types@12.1.3) swagger-ui-express: specifier: ^5.0.1 version: 5.0.1(express@5.2.1) + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@eslint/js': specifier: ^10.0.1 @@ -36,6 +42,9 @@ importers: '@types/node': specifier: ^25.5.0 version: 25.5.0 + '@types/nodemailer': + specifier: ^8.0.0 + version: 8.0.0 '@types/swagger-jsdoc': specifier: ^6.0.4 version: 6.0.4 @@ -226,6 +235,9 @@ packages: '@types/node@25.5.0': resolution: {integrity: sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==} + '@types/nodemailer@8.0.0': + resolution: {integrity: sha512-fyf8jWULsCo0d0BuoQ75i6IeoHs47qcqxWc7yUdUcV0pOZGjUTTOvwdG1PRXUDqN/8A64yQdQdnA2pZgcdi+cA==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -1039,6 +1051,10 @@ packages: resolution: {integrity: sha512-lDcBsjBSMlj3LXH2v/FW3txlh2pYTjmbOXPYJD93HI5EwuLzI11tdHSIpUMmfq/IOsldj4Ps8M8flhm+pCK4Ew==} engines: {node: '>=12.22.0'} + nodemailer@8.0.10: + resolution: {integrity: sha512-BLFuSth7QtHOkBzyqTehWWyub0NTRDuK2Q2SQfnGLsrJnzyU+Yeh4WpV1eZGuARFj1xQJHIdnTuJZLP+b9R1GQ==} + engines: {node: '>=6.0.0'} + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -1460,6 +1476,9 @@ packages: engines: {node: '>=8.0.0'} hasBin: true + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + snapshots: '@apidevtools/json-schema-ref-parser@9.1.2': @@ -1605,6 +1624,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.0': + dependencies: + '@types/node': 25.5.0 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} @@ -2432,6 +2455,8 @@ snapshots: transitivePeerDependencies: - supports-color + nodemailer@8.0.10: {} + normalize-path@3.0.0: {} object-inspect@1.13.4: {} @@ -2872,3 +2897,5 @@ snapshots: validator: 13.15.26 optionalDependencies: commander: 9.5.0 + + zod@4.4.3: {} diff --git a/src/app.ts b/src/app.ts index 0dcbdbd..7c4e013 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,6 +9,7 @@ import { errorHandler } from './middleware/error.middleware'; import swaggerUi from 'swagger-ui-express'; import { swaggerSpec } from './utils/swagger'; import metricsRoutes from './routes/metrics.routes'; +import emailRoutes from './routes/email.routes'; const app: Application = express(); @@ -24,6 +25,7 @@ app.use('/api/login', loginRoutes); app.use('/api/appointment', appointmentRoutes); app.use('/api/tips', tipsRoutes); app.use('/api/metrics/', metricsRoutes); +app.use('/api/email', emailRoutes); app.use(errorHandler); diff --git a/src/constants/email/email.constants.ts b/src/constants/email/email.constants.ts new file mode 100644 index 0000000..6c8561b --- /dev/null +++ b/src/constants/email/email.constants.ts @@ -0,0 +1,56 @@ +export const EMAIL_TYPE = { + ATTENDANCE_UPDATE: 'ATTENDANCE_UPDATE', +} as const; + +export const ATTENDANCE_STATUS = { + COMING: 'COMING', + NOT_COMING: 'NOT_COMING', +} as const; + +export const EMAIL_SUBJECTS = { + ATTENDANCE_COMING: 'אישור הגעה לטיפול', + ATTENDANCE_NOT_COMING: 'ביטול הגעה לטיפול', +} as const; + +export const EMAIL_TEXT = { + dearCaregiver: 'מטפל יקר', + patientNumber: 'מספר מטופל', + willCome: 'יגיע לטיפול', + willNotCome: 'לא יוכל להגיע לטיפול', + treatmentTime: 'בשעה', + cell: 'תא', + building: 'בניין', + rescheduleMessage: + 'אנא פנה למטופל לקביעת מועד חדש ועדכן במערכת כי התפנה כיסא.', + automaticMessage: 'הודעה זו נשלחה אוטומטית ממערכת Sagol360.', + patientNameLabel: 'שם מטופל', + patientNumberLabel: 'מספר מטופל', + attendanceStatusLabel: 'סטטוס הגעה', + timeLabel: 'שעה', + cellLabel: 'תא', + buildingLabel: 'בניין', + comingStatusLabel: 'מגיע לטיפול', + notComingStatusLabel: 'לא מגיע לטיפול', +} as const; + +export const EMAIL_ERRORS = { + invalidEmailData: 'Invalid email data', + unsupportedEmailType: 'Unsupported email type', + missingEmailEnvironmentVariables: 'Missing email environment variables', + emailWasNotSent: 'Email was not sent', +} as const; + +export const EMAIL_SUCCESS_MESSAGES = { + emailSentSuccessfully: 'Email sent successfully', +} as const; + +export const EMAIL_COLORS = { + comingHeader: '#1f7a4d', + notComingHeader: '#9f2f2f', + background: '#f4f6f8', + cardBackground: '#ffffff', + border: '#e0e0e0', + text: '#222222', + mutedText: '#777777', + tableLabelBackground: '#f8f9fa', +} as const; \ No newline at end of file diff --git a/src/controllers/email/email.controller.ts b/src/controllers/email/email.controller.ts new file mode 100644 index 0000000..0adcfee --- /dev/null +++ b/src/controllers/email/email.controller.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import { sendEmail as serviceSendEmail } from '@/services/email/email.service'; +import { EMAIL_ERRORS, EMAIL_SUCCESS_MESSAGES } from '@/constants/email/email.constants'; + +export const sendEmail = async (req: Request, res: Response) => { + try { + await serviceSendEmail(req.body); + + res.status(StatusCodes.OK).json({ + message: EMAIL_SUCCESS_MESSAGES.emailSentSuccessfully, + }); + } catch (error) { + const errorMessage = error instanceof Error ? error.message : EMAIL_ERRORS.emailWasNotSent; + + const statusCode = + errorMessage === EMAIL_ERRORS.invalidEmailData || + errorMessage === EMAIL_ERRORS.unsupportedEmailType + ? StatusCodes.BAD_REQUEST + : StatusCodes.INTERNAL_SERVER_ERROR; + + res.status(statusCode).json({ + message: errorMessage, + }); + } +}; diff --git a/src/routes/email.routes.ts b/src/routes/email.routes.ts new file mode 100644 index 0000000..4b80a4b --- /dev/null +++ b/src/routes/email.routes.ts @@ -0,0 +1,99 @@ +import { sendEmail } from '@/controllers/email/email.controller'; +import { Router } from 'express'; + +const router = Router(); + +/** + * @swagger + * /api/email: + * post: + * summary: Send an email + * description: Sends an attendance update email according to the provided email type and payload. + * tags: + * - Email + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - emailType + * - email + * - payload + * properties: + * emailType: + * type: string + * enum: + * - ATTENDANCE_UPDATE + * example: ATTENDANCE_UPDATE + * email: + * type: string + * format: email + * example: secretary@example.com + * payload: + * type: object + * required: + * - patientName + * - patientNumber + * - attendanceStatus + * - time + * - cell + * - building + * properties: + * patientName: + * type: string + * example: ישראל ישראלי + * patientNumber: + * type: string + * example: "123456" + * attendanceStatus: + * type: string + * enum: + * - COMING + * - NOT_COMING + * example: NOT_COMING + * time: + * type: string + * example: "18:00" + * cell: + * type: string + * example: כתום + * building: + * type: string + * example: אריסון + * responses: + * 200: + * description: Email sent successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Email sent successfully + * 400: + * description: Invalid email data or unsupported email type + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Invalid email data + * 500: + * description: Email was not sent or server email configuration is missing + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Email was not sent + */ +router.post('/', sendEmail); + +export default router; diff --git a/src/services/email/email.service.helpers.ts b/src/services/email/email.service.helpers.ts new file mode 100644 index 0000000..edbfe24 --- /dev/null +++ b/src/services/email/email.service.helpers.ts @@ -0,0 +1,118 @@ +import { + ATTENDANCE_STATUS, + EMAIL_COLORS, + EMAIL_SUBJECTS, + EMAIL_TEXT, +} from '@/constants/email/email.constants'; + +export interface AttendanceEmailPayload { + patientName: string; + patientNumber: string; + attendanceStatus: 'COMING' | 'NOT_COMING'; + time: string; + cell: string; + building: string; +} + +export interface EmailContent { + subject: string; + text: string; + html: string; +} + +const escapeHtml = (value: string): string => { + return value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +}; + +const getAttendanceSubject = (attendanceStatus: string): string => { + return attendanceStatus === ATTENDANCE_STATUS.COMING + ? EMAIL_SUBJECTS.ATTENDANCE_COMING + : EMAIL_SUBJECTS.ATTENDANCE_NOT_COMING; +}; + +const getAttendanceText = (attendanceStatus: string): string => { + return attendanceStatus === ATTENDANCE_STATUS.COMING + ? EMAIL_TEXT.willCome + : EMAIL_TEXT.willNotCome; +}; + +const buildAttendanceTextMessage = ({ + patientName, + patientNumber, + attendanceStatus, + time, + cell, + building, +}: AttendanceEmailPayload): string => { + const baseMessage = `${EMAIL_TEXT.dearCaregiver}, ${patientName} ${EMAIL_TEXT.patientNumber} ${patientNumber} ${getAttendanceText(attendanceStatus)} ${EMAIL_TEXT.treatmentTime} ${time}, ${EMAIL_TEXT.cell} ${cell}, ${EMAIL_TEXT.building} ${building}.`; + + if (attendanceStatus === ATTENDANCE_STATUS.NOT_COMING) { + return `${baseMessage} ${EMAIL_TEXT.rescheduleMessage}`; + } + return baseMessage; +}; + +const buildTableRow = (label: string, value: string): string => { + return ` + + + ${escapeHtml(label)} + + + ${escapeHtml(value)} + + + `; +}; + +export const buildAttendanceEmailContent = (payload: AttendanceEmailPayload): EmailContent => { + const isComing = payload.attendanceStatus === ATTENDANCE_STATUS.COMING; + const subject = getAttendanceSubject(payload.attendanceStatus); + const text = buildAttendanceTextMessage(payload); + + const attendanceStatusText = isComing + ? EMAIL_TEXT.comingStatusLabel + : EMAIL_TEXT.notComingStatusLabel; + + const html = ` +
+
+
+

+ ${escapeHtml(subject)} +

+
+ +
+

+ ${escapeHtml(text)} +

+ + + ${buildTableRow(EMAIL_TEXT.patientNameLabel, payload.patientName)} + ${buildTableRow(EMAIL_TEXT.patientNumberLabel, payload.patientNumber)} + ${buildTableRow(EMAIL_TEXT.attendanceStatusLabel, attendanceStatusText)} + ${buildTableRow(EMAIL_TEXT.timeLabel, payload.time)} + ${buildTableRow(EMAIL_TEXT.cellLabel, payload.cell)} + ${buildTableRow(EMAIL_TEXT.buildingLabel, payload.building)} +
+ +

+ ${escapeHtml(EMAIL_TEXT.automaticMessage)} +

+
+
+
+ `; + + return { + subject, + text, + html, + }; +}; diff --git a/src/services/email/email.service.ts b/src/services/email/email.service.ts new file mode 100644 index 0000000..9c5b683 --- /dev/null +++ b/src/services/email/email.service.ts @@ -0,0 +1,66 @@ +import nodemailer from 'nodemailer'; +import { z } from 'zod'; +import { ATTENDANCE_STATUS, EMAIL_ERRORS, EMAIL_TYPE } from '@/constants/email/email.constants'; +import { buildAttendanceEmailContent, EmailContent } from './email.service.helpers'; + +const attendanceEmailSchema = z.object({ + emailType: z.literal(EMAIL_TYPE.ATTENDANCE_UPDATE), + email: z.string().trim().email(), + payload: z.object({ + patientName: z.string().trim().min(1), + patientNumber: z.string().trim().min(1), + attendanceStatus: z.enum([ATTENDANCE_STATUS.COMING, ATTENDANCE_STATUS.NOT_COMING]), + time: z.string().trim().min(1), + cell: z.string().trim().min(1), + building: z.string().trim().min(1), + }), +}); + +const sendEmailSchema = z.discriminatedUnion('emailType', [attendanceEmailSchema]); + +type SendEmailData = z.infer; + +const transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: process.env.SMTP_USER, + pass: process.env.SMTP_PASS, + }, +}); + +const buildEmailContent = (data: SendEmailData): EmailContent => { + switch (data.emailType) { + case EMAIL_TYPE.ATTENDANCE_UPDATE: + return buildAttendanceEmailContent(data.payload); + + default: + throw new Error(EMAIL_ERRORS.unsupportedEmailType); + } +}; + +export const sendEmail = async (data: unknown): Promise => { + const validation = sendEmailSchema.safeParse(data); + + if (!validation.success) { + throw new Error(EMAIL_ERRORS.invalidEmailData); + } + + if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { + throw new Error(EMAIL_ERRORS.missingEmailEnvironmentVariables); + } + + const emailData = validation.data; + const { subject, text, html } = buildEmailContent(emailData); + + try { + await transporter.sendMail({ + from: process.env.SMTP_USER, + to: emailData.email, + subject: subject, + text: text, + html: html, + }); + } catch { + throw new Error(EMAIL_ERRORS.emailWasNotSent); + } +}; From 983e2610f2d537f49377b156fc1e7aab78a221b9 Mon Sep 17 00:00:00 2001 From: Elad Abutbul Date: Sun, 7 Jun 2026 13:50:31 +0300 Subject: [PATCH 2/2] move functions to diffrent folders --- src/schemas/email/attendance-email.schema.ts | 18 ++++++++ src/schemas/email/email.service.helpers.ts | 13 ++++++ src/services/email/email.service.ts | 46 +++++++------------- 3 files changed, 46 insertions(+), 31 deletions(-) create mode 100644 src/schemas/email/attendance-email.schema.ts create mode 100644 src/schemas/email/email.service.helpers.ts diff --git a/src/schemas/email/attendance-email.schema.ts b/src/schemas/email/attendance-email.schema.ts new file mode 100644 index 0000000..41876f8 --- /dev/null +++ b/src/schemas/email/attendance-email.schema.ts @@ -0,0 +1,18 @@ +import { ATTENDANCE_STATUS, EMAIL_TYPE } from '@/constants/email/email.constants'; +import { sendEmailSchema } from '@/services/email/email.service'; +import { z } from 'zod'; + +export const attendanceEmailSchema = z.object({ + emailType: z.literal(EMAIL_TYPE.ATTENDANCE_UPDATE), + email: z.string().trim().pipe(z.email()), + payload: z.object({ + patientName: z.string().trim().min(1), + patientNumber: z.string().trim().min(1), + attendanceStatus: z.enum([ATTENDANCE_STATUS.COMING, ATTENDANCE_STATUS.NOT_COMING]), + time: z.string().trim().min(1), + cell: z.string().trim().min(1), + building: z.string().trim().min(1), + }), +}); + +export type SendEmailData = z.infer; diff --git a/src/schemas/email/email.service.helpers.ts b/src/schemas/email/email.service.helpers.ts new file mode 100644 index 0000000..4d74772 --- /dev/null +++ b/src/schemas/email/email.service.helpers.ts @@ -0,0 +1,13 @@ +import { buildAttendanceEmailContent, EmailContent } from '@/services/email/email.service.helpers'; +import { SendEmailData } from './attendance-email.schema'; +import { EMAIL_ERRORS, EMAIL_TYPE } from '@/constants/email/email.constants'; + +export const buildEmailContent = (data: SendEmailData): EmailContent => { + switch (data.emailType) { + case EMAIL_TYPE.ATTENDANCE_UPDATE: + return buildAttendanceEmailContent(data.payload); + + default: + throw new Error(EMAIL_ERRORS.unsupportedEmailType); + } +}; diff --git a/src/services/email/email.service.ts b/src/services/email/email.service.ts index 9c5b683..118f359 100644 --- a/src/services/email/email.service.ts +++ b/src/services/email/email.service.ts @@ -1,24 +1,10 @@ import nodemailer from 'nodemailer'; import { z } from 'zod'; -import { ATTENDANCE_STATUS, EMAIL_ERRORS, EMAIL_TYPE } from '@/constants/email/email.constants'; -import { buildAttendanceEmailContent, EmailContent } from './email.service.helpers'; +import { EMAIL_ERRORS } from '@/constants/email/email.constants'; +import { attendanceEmailSchema } from '@/schemas/email/attendance-email.schema'; +import { buildEmailContent } from '@/schemas/email/email.service.helpers'; -const attendanceEmailSchema = z.object({ - emailType: z.literal(EMAIL_TYPE.ATTENDANCE_UPDATE), - email: z.string().trim().email(), - payload: z.object({ - patientName: z.string().trim().min(1), - patientNumber: z.string().trim().min(1), - attendanceStatus: z.enum([ATTENDANCE_STATUS.COMING, ATTENDANCE_STATUS.NOT_COMING]), - time: z.string().trim().min(1), - cell: z.string().trim().min(1), - building: z.string().trim().min(1), - }), -}); - -const sendEmailSchema = z.discriminatedUnion('emailType', [attendanceEmailSchema]); - -type SendEmailData = z.infer; +export const sendEmailSchema = z.discriminatedUnion('emailType', [attendanceEmailSchema]); const transporter = nodemailer.createTransport({ service: 'gmail', @@ -28,27 +14,22 @@ const transporter = nodemailer.createTransport({ }, }); -const buildEmailContent = (data: SendEmailData): EmailContent => { - switch (data.emailType) { - case EMAIL_TYPE.ATTENDANCE_UPDATE: - return buildAttendanceEmailContent(data.payload); - - default: - throw new Error(EMAIL_ERRORS.unsupportedEmailType); - } -}; - export const sendEmail = async (data: unknown): Promise => { const validation = sendEmailSchema.safeParse(data); if (!validation.success) { + console.error(EMAIL_ERRORS.invalidEmailData, validation.error); throw new Error(EMAIL_ERRORS.invalidEmailData); } if (!process.env.SMTP_USER || !process.env.SMTP_PASS) { + console.error(EMAIL_ERRORS.missingEmailEnvironmentVariables, { + hasSmtpUser: Boolean(process.env.SMTP_USER), + hasSmtpPass: Boolean(process.env.SMTP_PASS), + }); + throw new Error(EMAIL_ERRORS.missingEmailEnvironmentVariables); } - const emailData = validation.data; const { subject, text, html } = buildEmailContent(emailData); @@ -60,7 +41,10 @@ export const sendEmail = async (data: unknown): Promise => { text: text, html: html, }); - } catch { - throw new Error(EMAIL_ERRORS.emailWasNotSent); + } catch (error) { + console.error(EMAIL_ERRORS.emailWasNotSent, error); + throw new Error(EMAIL_ERRORS.emailWasNotSent, { + cause: error, + }); } };