diff --git a/server/package.json b/server/package.json index bdd527a4..f60a66be 100644 --- a/server/package.json +++ b/server/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@hapi/joi": "^17.1.1", + "@sendgrid/mail": "^7.2.4", "africastalking": "^0.4.5", "argon2": "^0.26.2", "axios": "^0.19.2", diff --git a/server/src/core/app.ts b/server/src/core/app.ts index ac192828..ad605921 100644 --- a/server/src/core/app.ts +++ b/server/src/core/app.ts @@ -107,6 +107,8 @@ export interface AppConfig { * Google API Client ID */ googleClientId: string; + sendgridApiKey: string; + emailSender: string; }; export function loadAppConfigFromEnv(env: { [key: string]: string }): AppConfig { @@ -133,6 +135,8 @@ export function loadAppConfigFromEnv(env: { [key: string]: string }): AppConfig distributionPeriodLength: (env.DISTRIBUTION_PERIOD_LENGTH && Number(env.DISTRIBUTION_PERIOD_LENGTH)) || 30, distributionInterval: (env.DISTRIBUTION_INTERVAL && Number(env.DISTRIBUTION_INTERVAL)) || 1, statsComputationInterval: (env.STATS_COMPUTATION_INTERVAL && Number(env.STATS_COMPUTATION_INTERVAL)) || 1, - googleClientId: env.GOOGLE_CLIENT_ID + googleClientId: env.GOOGLE_CLIENT_ID, + sendgridApiKey: env.SENDGRID_API_KEY || '', + emailSender: env.EMAIL_SENDER || 's' }; } \ No newline at end of file diff --git a/server/src/core/bootstrap.ts b/server/src/core/bootstrap.ts index 44610797..7d774380 100644 --- a/server/src/core/bootstrap.ts +++ b/server/src/core/bootstrap.ts @@ -6,6 +6,7 @@ import { createDbConnectionFailedError } from './error'; import { DonationDistributions } from './distribution'; import { SystemLocks } from './system-lock'; import { AtSmsProvider } from './sms'; +import { SendGridEmailProvider } from './email'; import { Invitations } from './invitation'; import { EventBus } from './event'; import { UserNotifications } from './user-notification'; @@ -61,10 +62,15 @@ export async function bootstrap(config: AppConfig): Promise { apiKey: config.atApiKey, sender: config.atSmsSender }); + const emailProvider = new SendGridEmailProvider({ + apiKey: config.sendgridApiKey, + emailSender: config.emailSender + }); // starts listening to events when instantiated new UserNotifications({ smsProvider, + emailProvider, eventBus, users, webappBaseUrl: config.webappBaseUrl diff --git a/server/src/core/email/index.ts b/server/src/core/email/index.ts new file mode 100644 index 00000000..dfad3e9e --- /dev/null +++ b/server/src/core/email/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export { SendGridEmailProvider } from './sendgrid-email-provider'; \ No newline at end of file diff --git a/server/src/core/email/sendgrid-email-provider.ts b/server/src/core/email/sendgrid-email-provider.ts new file mode 100644 index 00000000..b0ef0a45 --- /dev/null +++ b/server/src/core/email/sendgrid-email-provider.ts @@ -0,0 +1,38 @@ +import { EmailProvider } from './types'; +import { rethrowIfAppError, createEmailDeliveryFailedError, createSendGridApiError } from '../error'; +import sgMail = require('@sendgrid/mail'); + +export interface SendGridEmailProviderArgs { + apiKey: string, + emailSender: string +}; + +export class SendGridEmailProvider implements EmailProvider{ + private emailSender: string; + + constructor(args: SendGridEmailProviderArgs) { + sgMail.setApiKey(args.apiKey); + this.emailSender = args.emailSender; + } + + async sendEmail(to: string, message: string): Promise { + try { + const res = await sgMail.send({ + to, + from: this.emailSender, + subject: 'Social Relief Notification', + text: message, + }); + + if (res[0].statusCode !== 202) { + throw createEmailDeliveryFailedError('Failed to send email'); + } + } + catch (e) { + console.log(e); + rethrowIfAppError(e); + throw createSendGridApiError(e.message); + } + } +} + \ No newline at end of file diff --git a/server/src/core/email/types.ts b/server/src/core/email/types.ts new file mode 100644 index 00000000..42dc9174 --- /dev/null +++ b/server/src/core/email/types.ts @@ -0,0 +1,3 @@ +export interface EmailProvider { + sendEmail(to: string, message: string): Promise; +} \ No newline at end of file diff --git a/server/src/core/error.ts b/server/src/core/error.ts index de8d6ec2..ae26fe10 100644 --- a/server/src/core/error.ts +++ b/server/src/core/error.ts @@ -54,6 +54,7 @@ export type ErrorCode = | 'b2cRequestFailed' | 'serverError' | 'atApiError' + | 'sendgridApiError' | 'manualPayApiError' | 'flutterwaveApiError' | 'serverError' @@ -64,6 +65,7 @@ export type ErrorCode = | 'systemLockLocked' | 'systemLockInvalidState' | 'messageDeliveryFailed' + | 'emailDeliveryFailed' /** * This error should only be thrown when a transaction fails * because the user's transactions are blocked (based on the transactionsBlockedReason field) @@ -107,6 +109,10 @@ export function createAtApiError(message: string = messages.ERROR_AT_API_ERROR) return createAppError(message, 'atApiError'); } +export function createSendGridApiError(message: string) { + return createAppError(message, 'sendgridApiError'); +} + export function createManualPayApiError(message: string) { return createAppError(message, 'manualPayApiError'); } @@ -147,6 +153,10 @@ export function createMessageDeliveryFailedError(message: string) { return createAppError(message, 'messageDeliveryFailed'); } +export function createEmailDeliveryFailedError(message: string) { + return createAppError(message, 'emailDeliveryFailed'); +} + /** * This error should only be thrown when a transaction fails * because the user's transactions are blocked (based on the transactionsBlockedReason field) diff --git a/server/src/core/user-notification/user-notification.ts b/server/src/core/user-notification/user-notification.ts index 1fc07b29..1e2337b6 100644 --- a/server/src/core/user-notification/user-notification.ts +++ b/server/src/core/user-notification/user-notification.ts @@ -1,10 +1,12 @@ import { SmsProvider } from '../sms'; +import { EmailProvider } from '../email'; import { EventBus, Event} from '../event'; import { UserService, UserInvitationEventData } from '../user'; import { TransactionCompletedEventData, Transaction } from '../payment'; export interface UserNotificationsArgs { smsProvider: SmsProvider; + emailProvider: EmailProvider; eventBus: EventBus; users: UserService; webappBaseUrl: string; @@ -12,12 +14,14 @@ export interface UserNotificationsArgs { export class UserNotifications { smsProvider: SmsProvider; + emailProvider: EmailProvider; eventBus: EventBus; users: UserService; webappBaseUrl: string; constructor(args: UserNotificationsArgs) { this.smsProvider = args.smsProvider; + this.emailProvider = args.emailProvider; this.eventBus = args.eventBus; this.users = args.users; this.webappBaseUrl = args.webappBaseUrl; @@ -37,6 +41,9 @@ export class UserNotifications { try { await this.smsProvider.sendSms(data.recipientPhone, message); + if (data.recipientEmail) { + await this.emailProvider.sendEmail(data.recipientEmail, message); + } } catch (error) { console.error('Error occurred handling event', event, error); @@ -72,11 +79,22 @@ export class UserNotifications { this.smsProvider.sendSms(donor.phone, donorMessage), this.smsProvider.sendSms(beneficiary.phone, beneficiaryMessage) ]); + + if (donor.email) { + await this.emailProvider.sendEmail(donor.email, donorMessage); + } + + if (beneficiary.email) { + await this.emailProvider.sendEmail(beneficiary.email, beneficiaryMessage); + } } async sendSuccessfulRefundMessage(transaction: Transaction) { const user = await this.users.getById(transaction.to); const message = `Hello ${user.name}, your refund of Ksh ${transaction.amount} from SocialRelief has been issued.`; await this.smsProvider.sendSms(user.phone, message); + if (user.email) { + await this.emailProvider.sendEmail(user.email, message); + } } } diff --git a/server/src/core/user/types.ts b/server/src/core/user/types.ts index b50b6871..f71dc3be 100644 --- a/server/src/core/user/types.ts +++ b/server/src/core/user/types.ts @@ -113,6 +113,7 @@ export interface UserInvitationEventData { senderName: string, recipientName: string, recipientPhone: string, + recipientEmail: string, role: string, invitationId: string } diff --git a/server/src/core/user/user-service.ts b/server/src/core/user/user-service.ts index a29c2ea1..6ce12f1c 100644 --- a/server/src/core/user/user-service.ts +++ b/server/src/core/user/user-service.ts @@ -192,6 +192,7 @@ export class Users implements UserService { this.eventBus.emitUserInvitationCreated({ recipientName: invitation.inviteeName, recipientPhone: invitation.inviteePhone, + recipientEmail: invitation.inviteeEmail, senderName: invitation.invitorName, role: invitation.inviteeRole, invitationId: invitation._id diff --git a/server/src/rest/middleware.ts b/server/src/rest/middleware.ts index b0aae204..a377618c 100644 --- a/server/src/rest/middleware.ts +++ b/server/src/rest/middleware.ts @@ -36,6 +36,7 @@ export const errorHandler = (): ErrorRequestHandler => case 'validationError': case 'transactionRejected': case 'insufficientFunds': + case 'emailDeliveryFailed': return sendErrorResponse(res, statusCodes.STATUS_BAD_REQUEST, error); case 'messageDeliveryFailed': return sendErrorResponse(res, statusCodes.INTERNAL_SERVER_ERROR, error); diff --git a/server/yarn.lock b/server/yarn.lock index 9806414c..fcade4d0 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -541,6 +541,30 @@ dependencies: safe-buffer "^5.1.2" +"@sendgrid/client@^7.2.4": + version "7.2.4" + resolved "https://registry.yarnpkg.com/@sendgrid/client/-/client-7.2.4.tgz#8a7aaa4b16c05c514a1ff93b8ac2613ef80813e8" + integrity sha512-zL56B6f/ftJVnJKtJnXA8g/cvRGOyHAbXeAizYVsG+bjqd4hyRVDAKimHW59b2BvFXLLrphc9yGBt7AKOLJ5Tg== + dependencies: + "@sendgrid/helpers" "^7.2.4" + axios "^0.19.2" + +"@sendgrid/helpers@^7.2.4": + version "7.2.4" + resolved "https://registry.yarnpkg.com/@sendgrid/helpers/-/helpers-7.2.4.tgz#82544a4b7d9a905ce5e0b4f96fcd9b2a73129337" + integrity sha512-h8bBRXpjQLM0Zl08EGJPxVOPWBaXjEZZCM3IurxWn8RaVFYDGPVZ4pv4ZW9AAmsujntZXuIxSsmB4aQczg/ivw== + dependencies: + chalk "^2.0.1" + deepmerge "^4.2.2" + +"@sendgrid/mail@^7.2.4": + version "7.2.4" + resolved "https://registry.yarnpkg.com/@sendgrid/mail/-/mail-7.2.4.tgz#7633a5ff8cacf6e8df1f51ff2e24cae6c72d448c" + integrity sha512-aBq1LJfp/1yFPVso/hG8FvWdWaVlYQJFVhzMQD8SW8H2MVhPN6WEJsnXw/zNGHxPa5uzO8r7j36ugDOh1LMPDA== + dependencies: + "@sendgrid/client" "^7.2.4" + "@sendgrid/helpers" "^7.2.4" + "@sinonjs/commons@^1.7.0": version "1.7.2" resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.7.2.tgz#505f55c74e0272b43f6c52d81946bed7058fc0e2" @@ -1279,7 +1303,7 @@ caseless@~0.12.0: resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw= -chalk@^2.0.0: +chalk@^2.0.0, chalk@^2.0.1: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==