diff --git a/packages/server/__tests__/api/grants.test.js b/packages/server/__tests__/api/grants.test.js index d910be4aa..ba7f6c215 100644 --- a/packages/server/__tests__/api/grants.test.js +++ b/packages/server/__tests__/api/grants.test.js @@ -178,7 +178,7 @@ describe('`/api/grants` endpoint', () => { const assignEndpoint = `333816/assign/agencies`; context('by a user with admin role', () => { it('assigns this user\'s own agency to a grant', async () => { - const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmail'); + const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmails'); const response = await fetchApi(`/grants/${assignEndpoint}`, agencies.own, { ...fetchOptions.admin, method: 'put', @@ -189,7 +189,7 @@ describe('`/api/grants` endpoint', () => { expect(emailSpy.called).to.equal(true); }); it('assigns subagencies of this user\'s own agency to a grant', async () => { - const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmail'); + const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmails'); const response = await fetchApi(`/grants/${assignEndpoint}`, agencies.ownSub, { ...fetchOptions.admin, method: 'put', @@ -200,7 +200,7 @@ describe('`/api/grants` endpoint', () => { expect(emailSpy.called).to.equal(true); }); it('forbids requests for any agency outside this user\'s hierarchy', async () => { - const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmail'); + const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmails'); const response = await fetchApi(`/grants/${assignEndpoint}`, agencies.offLimits, { ...fetchOptions.admin, method: 'put', @@ -212,7 +212,7 @@ describe('`/api/grants` endpoint', () => { }); context('by a user with staff role', () => { it('assigns this user\'s own agency to a grant', async () => { - const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmail'); + const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmails'); const response = await fetchApi(`/grants/${assignEndpoint}`, agencies.own, { ...fetchOptions.staff, method: 'put', @@ -223,7 +223,7 @@ describe('`/api/grants` endpoint', () => { expect(emailSpy.called).to.equal(true); }); it('forbids requests for any agency except this user\'s own agency', async () => { - const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmail'); + const emailSpy = sandbox.spy(email, 'sendGrantAssignedEmails'); let response = await fetchApi(`/grants/${assignEndpoint}`, agencies.ownSub, { ...fetchOptions.staff, method: 'put', diff --git a/packages/server/__tests__/api/users.test.js b/packages/server/__tests__/api/users.test.js index 46a8b7b2c..2c0b28371 100644 --- a/packages/server/__tests__/api/users.test.js +++ b/packages/server/__tests__/api/users.test.js @@ -1,8 +1,10 @@ const { expect } = require('chai'); const sinon = require('sinon'); +const moment = require('moment'); const { getSessionCookie, makeTestServer, knex } = require('./utils'); const email = require('../../src/lib/email'); const emailConstants = require('../../src/lib/email/constants'); +const emailService = require('../../src/lib/email/service-email'); describe('`/api/users` endpoint', () => { const agencies = { @@ -367,7 +369,8 @@ describe('`/api/users` endpoint', () => { context('GET /users/:userId/sendDigestEmail (admin send digest email for a specific user)', () => { beforeEach(async () => { - this.clockFn = (date) => sinon.useFakeTimers(new Date(date)); + // Set clock to given date in local time zone + this.clockFn = (date) => sinon.useFakeTimers(moment(date).toDate()); this.clock = this.clockFn('2021-08-06'); }); afterEach(async () => { @@ -376,48 +379,48 @@ describe('`/api/users` endpoint', () => { }); context('by a user with admin role', () => { it('Sends an email based on this user\'s saved searches', async () => { - const deliverEmailSpy = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', deliverEmailSpy); + const sendEmailSpy = sinon.fake(); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendEmailSpy })); const response = await fetchApi( `/users/2/sendDigestEmail`, agencies.own, { ...fetchOptions.admin }, ); expect(response.statusText).to.equal('OK'); - expect(deliverEmailSpy.calledOnce).to.equal(true); + expect(sendEmailSpy.calledOnce).to.equal(true); }); it('Sends an email based on this user\'s saved searches date specified', async () => { - const deliverEmailSpy = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', deliverEmailSpy); + const sendEmailSpy = sinon.fake(); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendEmailSpy })); const response = await fetchApi( `/users/2/sendDigestEmail?date=2021-08-05`, agencies.own, { ...fetchOptions.admin }, ); expect(response.statusText).to.equal('OK'); - expect(deliverEmailSpy.calledOnce).to.equal(true); + expect(sendEmailSpy.calledOnce).to.equal(true); }); it('Sends an email based on this user\'s saved searches at a time without grants', async () => { - const deliverEmailSpy = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', deliverEmailSpy); + const sendEmailSpy = sinon.fake(); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendEmailSpy })); const response = await fetchApi( `/users/2/sendDigestEmail?date=1985-08-06`, agencies.own, { ...fetchOptions.admin }, ); expect(response.statusText).to.equal('OK'); - expect(deliverEmailSpy.calledOnce).to.equal(false); + expect(sendEmailSpy.calledOnce).to.equal(false); }); it('Sends an email based on this user\'s saved searches without admin rights', async () => { - const deliverEmailSpy = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', deliverEmailSpy); + const sendEmailSpy = sinon.fake(); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendEmailSpy })); const response = await fetchApi( `/users/2/sendDigestEmail`, agencies.own, { ...fetchOptions.nonUSDRAdmin }, ); expect(response.statusText).to.equal('Forbidden'); - expect(deliverEmailSpy.calledOnce).to.equal(false); + expect(sendEmailSpy.calledOnce).to.equal(false); }); }); context('When the user is not subscribed', () => { @@ -454,15 +457,15 @@ describe('`/api/users` endpoint', () => { ); }); it('updates this user\'s own agency', async () => { - const deliverEmailSpy = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', deliverEmailSpy); + const sendEmailSpy = sinon.fake(); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendEmailSpy })); const response = await fetchApi( `/users/2/sendDigestEmail`, agencies.own, { ...fetchOptions.admin }, ); expect(response.statusText).to.equal('Bad Request'); - expect(deliverEmailSpy.calledOnce).to.equal(false); + expect(sendEmailSpy.calledOnce).to.equal(false); }); }); }); diff --git a/packages/server/__tests__/arpa_reporter/server/lib/audit-report.spec.js b/packages/server/__tests__/arpa_reporter/server/lib/audit-report.spec.js index b60412126..e1d7671f0 100644 --- a/packages/server/__tests__/arpa_reporter/server/lib/audit-report.spec.js +++ b/packages/server/__tests__/arpa_reporter/server/lib/audit-report.spec.js @@ -44,7 +44,7 @@ describe('audit report generation', () => { expect(sendFake.calledOnce).to.equal(true); expect(sendFake.firstCall.args[0]).to.equal('foo@example.com'); expect(sendFake.firstCall.args[1]).to.equal(`${process.env.API_DOMAIN}/api/audit_report/1/99/example.xlsx`); - expect(sendFake.firstCall.args[2]).to.equal('audit'); + expect(sendFake.firstCall.args[2]).to.equal(email.ASYNC_REPORT_TYPES.audit); }); it('generateAndSendEmail generates a report, uploads to s3, and sends an email', async () => { const sendFake = sandbox.fake.returns('foo'); diff --git a/packages/server/__tests__/db/db.test.js b/packages/server/__tests__/db/db.test.js index 75373ef4b..9d977b672 100644 --- a/packages/server/__tests__/db/db.test.js +++ b/packages/server/__tests__/db/db.test.js @@ -40,9 +40,11 @@ describe('db', () => { 'DRY RUN :: Begin migrating legacy agency criteria to saved searches', 'DRY RUN :: Migrating agency criteria for agency 0', 'DRY RUN :: No agency criteria to migrate for agency 1', + 'DRY RUN :: No agency criteria to migrate for agency 2', 'DRY RUN :: Migrating agency criteria for agency 4', 'DRY RUN :: Migrating agency criteria for users 1,2 belonging to agency 0', 'DRY RUN :: No agency criteria to migrate for users 3 belonging to agency 1', + 'DRY RUN :: No agency criteria to migrate for users 4 belonging to agency 2', 'DRY RUN :: No users to migrate for agency 4', 'DRY RUN :: Would have inserted approximately 2 saved searches. Note: there may be duplicates.', 'DRY RUN :: Done migrating legacy agency criteria to saved searches', @@ -64,9 +66,11 @@ describe('db', () => { 'Begin migrating legacy agency criteria to saved searches', 'Migrating agency criteria for agency 0', 'No agency criteria to migrate for agency 1', + 'No agency criteria to migrate for agency 2', 'Migrating agency criteria for agency 4', 'Migrating agency criteria for users 1,2 belonging to agency 0', 'No agency criteria to migrate for users 3 belonging to agency 1', + 'No agency criteria to migrate for users 4 belonging to agency 2', 'No users to migrate for agency 4', 'Inserted 2 saved searches', 'Done migrating legacy agency criteria to saved searches', @@ -109,9 +113,11 @@ describe('db', () => { 'Begin migrating legacy agency criteria to saved searches', 'Migrating agency criteria for agency 0', 'No agency criteria to migrate for agency 1', + 'No agency criteria to migrate for agency 2', 'Migrating agency criteria for agency 4', 'Migrating agency criteria for users 1,2 belonging to agency 0', 'No agency criteria to migrate for users 3 belonging to agency 1', + 'No agency criteria to migrate for users 4 belonging to agency 2', 'No users to migrate for agency 4', 'Inserted 1 saved searches', // This would have been 2 if not for the duplication mechanism. 'Done migrating legacy agency criteria to saved searches', @@ -797,6 +803,17 @@ describe('db', () => { }); }); + context('getUserIdForEmail', () => { + it('returns id when user exists', async () => { + const result = await db.getUserIdForEmail(fixtures.users.adminUser.email); + expect(result).to.equal(fixtures.users.adminUser.id); + }); + it('returns null when user does not exist', async () => { + const result = await db.getUserIdForEmail('notauser@google.com'); + expect(result).to.be.null; + }); + }); + context('getNewGrantsForAgency', () => { beforeEach(() => { this.clockFn = (date) => sinon.useFakeTimers(new Date(date)); diff --git a/packages/server/__tests__/db/seeds/fixtures.js b/packages/server/__tests__/db/seeds/fixtures.js index 7b9e2c5cf..4d737bbb6 100644 --- a/packages/server/__tests__/db/seeds/fixtures.js +++ b/packages/server/__tests__/db/seeds/fixtures.js @@ -11,8 +11,12 @@ const tenants = { id: 0, display_name: 'SBA', }, - FS: { + USDR: { id: 1, + display_name: 'USDR', + }, + FS: { + id: 2, display_name: 'FS', }, }; @@ -34,6 +38,14 @@ const agencies = { parent: 0, tenant_id: tenants.SBA.id, }, + usdr: { + id: 2, + abbreviation: 'USDR', + code: 'USDR', + name: 'United States Digital Response', + parent: null, + tenant_id: tenants.USDR.id, + }, fleetServices: { id: 4, abbreviation: 'FSD', @@ -69,6 +81,14 @@ const users = { id: 3, tenant_id: agencies.subAccountancy.tenant_id, }, + usdrUser: { + email: 'usdr.volunteer@test.com', + name: 'USDR user', + agency_id: agencies.usdr.id, + role_id: roles.staffRole.id, + id: 4, + tenant_id: agencies.usdr.tenant_id, + }, }; const keywords = { diff --git a/packages/server/__tests__/email/email.test.js b/packages/server/__tests__/email/email.test.js index 0ef6bfc2b..099d828d3 100644 --- a/packages/server/__tests__/email/email.test.js +++ b/packages/server/__tests__/email/email.test.js @@ -3,13 +3,13 @@ const { expect } = require('chai'); const moment = require('moment'); const sinon = require('sinon'); +const _ = require('lodash'); require('dotenv').config(); const emailService = require('../../src/lib/email/service-email'); const email = require('../../src/lib/email'); const fixtures = require('../db/seeds/fixtures'); const db = require('../../src/db'); const awsTransport = require('../../src/lib/gost-aws'); -const emailConstants = require('../../src/lib/email/constants'); const { TEST_EMAIL_RECIPIENT, @@ -30,6 +30,7 @@ const testEmail = { toAddress: TEST_EMAIL_RECIPIENT || 'nobody@example.com', subject: 'Test email', body: 'This is a test email.', + tags: ['key=value'], }; describe('Email module', () => { @@ -204,137 +205,158 @@ describe('Email sender', () => { sandbox.restore(); }); - context('grant assigned email', () => { - it('deliverEmail calls the transport function with appropriate parameters', async () => { + context('send passcode email', () => { + it('calls the transport function with appropriate parameters', async () => { const sendFake = sinon.fake.returns('foo'); sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - email.deliverEmail({ - fromName: 'Foo', - toAddress: 'foo@bar.com', - ccAddress: 'cc@example.com', - emailHTML: '

foo

', - emailPlain: 'foo', - subject: 'test foo email', - }); + await email.sendPassCodeEmail('staff.user@test.com', '83c7c74a-6b38-4392-84fa-d1f3993f448d', 'https://api.grants.usdigitalresponse.org'); expect(sendFake.calledOnce).to.equal(true); - expect(sendFake.firstCall.args).to.deep.equal([{ - fromName: 'Foo', - toAddress: 'foo@bar.com', - ccAddress: 'cc@example.com', - subject: 'test foo email', - body: '

foo

', - text: 'foo', - }]); + expect(_.omit(sendFake.firstCall.args[0], ['body'])).to.deep.equal({ + fromName: 'USDR Grants', + ccAddress: undefined, + toAddress: 'staff.user@test.com', + subject: 'USDR Grants Tool Access Link', + text: 'Your link to access USDR\'s Grants tool is https://api.grants.usdigitalresponse.org/api/sessions?passcode=83c7c74a-6b38-4392-84fa-d1f3993f448d. It expires in 30 minutes', + tags: ['notification_type=passcode', 'user_role=staff', 'organization_id=0', 'team_id=0'], + }); + expect(sendFake.firstCall.args[0].body).contains('Login Passcode'); }); - it('sendGrantAssignedEmail ensures email is sent for all agencies', async () => { + }); + context('send welcome email', () => { + it('calls the transport function with appropriate parameters', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'sendGrantAssignedNotficationForAgency', sendFake); - - await email.sendGrantAssignedEmail({ grantId: '335255', agencyIds: [0, 1], userId: 1 }); - - expect(sendFake.calledTwice).to.equal(true); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - expect(sendFake.firstCall.firstArg.name).to.equal('State Board of Accountancy'); - expect(sendFake.firstCall.args[1].includes(' { + }); + context('send grant assigned email', () => { + it('calls the transport function with appropriate parameters', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); - await db.setUserEmailSubscriptionPreference( - fixtures.users.adminUser.id, - fixtures.agencies.accountancy.id, + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); + + await email.sendGrantAssignedEmails({ grantId: '335255', agencyIds: [0, 1], userId: 1 }); + + // There are 3 total users in agencies 0 and 1 and none are explicitly unsubscribed for grant assignment + // notifications which means they are all implicitly subscribed. + expect(sendFake.callCount).to.equal(3); + expect(sendFake.calledWithMatch( { - [emailConstants.notificationType.grantAssignment]: emailConstants.emailSubscriptionStatus.subscribed, + fromName: 'USDR Federal Grant Finder', + toAddress: 'staff.user@test.com', + subject: 'Grant Assigned to State Board of Accountancy', + tags: ['notification_type=grant_assignment', 'user_role=staff', 'organization_id=0', 'team_id=0'], }, - ); - await db.setUserEmailSubscriptionPreference( - fixtures.users.staffUser.id, - fixtures.agencies.accountancy.id, + )).to.be.true; + expect(sendFake.calledWithMatch( { - [emailConstants.notificationType.grantAssignment]: emailConstants.emailSubscriptionStatus.subscribed, + fromName: 'USDR Federal Grant Finder', + toAddress: 'sub.staff.user@test.com', + subject: 'Grant Assigned to State Board of Sub Accountancy', + tags: ['notification_type=grant_assignment', 'user_role=staff', 'organization_id=0', 'team_id=1'], }, - ); - - await email.sendGrantAssignedNotficationForAgency(fixtures.agencies.accountancy, '

sample html

', fixtures.users.adminUser.id); - - expect(sendFake.calledTwice).to.equal(true); - - expect(sendFake.firstCall.args.length).to.equal(3); - expect(sendFake.firstCall.args[0].toAddress).to.equal(fixtures.users.adminUser.email); - expect(sendFake.firstCall.args[0].emailHTML.includes(' { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'sendGrantAssignedNotficationForAgency', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); const grant = fixtures.grants.noDescOrEligibilityCodes; - await email.sendGrantAssignedEmail({ grantId: grant.grant_id, agencyIds: [0], userId: 1 }); + await email.sendGrantAssignedEmails({ grantId: grant.grant_id, agencyIds: [0], userId: 1 }); expect(sendFake.called).to.equal(true); - expect(sendFake.firstCall.args[1].includes('... View on')).to.equal(true); + expect(sendFake.firstCall.args[0].body.includes('... View on')).to.equal(true); }); }); - context('async report email', () => { + context('send async report email', () => { it('sendAsyncReportEmail delivers an email with the signedURL for audit report', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - await email.sendAsyncReportEmail('foo@example.com', 'https://example.usdigitalresponse.org', email.ASYNC_REPORT_TYPES.audit); + await email.sendAsyncReportEmail('usdr.volunteer@test.com', 'https://example.usdigitalresponse.org', email.ASYNC_REPORT_TYPES.audit); expect(sendFake.calledOnce).to.equal(true); expect(sendFake.firstCall.firstArg.subject).to.equal('Your audit report is ready for download'); - expect(sendFake.firstCall.firstArg.emailPlain).to.equal('Your audit report is ready for download. Paste this link into your browser to download it: https://example.usdigitalresponse.org This link will remain active for 7 days.'); - expect(sendFake.firstCall.firstArg.toAddress).to.equal('foo@example.com'); - expect(sendFake.firstCall.firstArg.emailHTML).contains('https://example.usdigitalresponse.org'); + expect(sendFake.firstCall.firstArg.text).to.equal('Your audit report is ready for download. Paste this link into your browser to download it: https://example.usdigitalresponse.org This link will remain active for 7 days.'); + expect(sendFake.firstCall.firstArg.toAddress).to.equal('usdr.volunteer@test.com'); + expect(sendFake.firstCall.firstArg.fromName).to.equal('USDR ARPA Reporter'); + expect(sendFake.firstCall.firstArg.body).contains('https://example.usdigitalresponse.org'); + expect(sendFake.firstCall.firstArg.tags).to.deep.equal([ + 'notification_type=audit_report', + 'user_role=usdr_staff', + 'organization_id=1', + 'team_id=2', + ]); }); it('sendAsyncReportEmail delivers an email with the signedURL for treasury report', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - await email.sendAsyncReportEmail('foo@example.com', 'https://example.usdigitalresponse.org', email.ASYNC_REPORT_TYPES.treasury); + await email.sendAsyncReportEmail('admin.user@test.com', 'https://example.usdigitalresponse.org', email.ASYNC_REPORT_TYPES.treasury); expect(sendFake.calledOnce).to.equal(true); expect(sendFake.firstCall.firstArg.subject).to.equal('Your treasury report is ready for download'); - expect(sendFake.firstCall.firstArg.emailPlain).to.equal('Your treasury report is ready for download. Paste this link into your browser to download it: https://example.usdigitalresponse.org This link will remain active for 7 days.'); - expect(sendFake.firstCall.firstArg.toAddress).to.equal('foo@example.com'); - expect(sendFake.firstCall.firstArg.emailHTML).contains('https://example.usdigitalresponse.org'); + expect(sendFake.firstCall.firstArg.text).to.equal('Your treasury report is ready for download. Paste this link into your browser to download it: https://example.usdigitalresponse.org This link will remain active for 7 days.'); + expect(sendFake.firstCall.firstArg.toAddress).to.equal('admin.user@test.com'); + expect(sendFake.firstCall.firstArg.fromName).to.equal('USDR ARPA Reporter'); + expect(sendFake.firstCall.firstArg.body).contains('https://example.usdigitalresponse.org'); + expect(sendFake.firstCall.firstArg.tags).to.deep.equal([ + 'notification_type=treasury_report', + 'user_role=admin', + 'organization_id=0', + 'team_id=0', + ]); }); }); - context('report error email', () => { + context('send report error email', () => { it('sendReportErrorEmail delivers an email with the error message', async () => { const sendFake = sinon.fake.returns('foo'); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); + const user = { email: 'foo@example.com', tenant: { display_name: 'Test Tenant', }, }; - sinon.replace(email, 'deliverEmail', sendFake); - const body = 'There was an error generating a your requested Audit Report. Someone from USDR will reach out within 24 hours to debug the problem. We apologize for any inconvenience.'; + const body = 'There was an error generating your requested audit report. Someone from USDR will reach out within 24 hours to debug the problem. We apologize for any inconvenience.'; - await email.sendReportErrorEmail(user, 'Audit'); + await email.sendReportErrorEmail(user, email.ASYNC_REPORT_TYPES.audit); expect(sendFake.calledOnce).to.equal(true); - expect(sendFake.firstCall.firstArg.subject).to.equal(`Audit Report generation has failed for Test Tenant`); - expect(sendFake.firstCall.firstArg.emailPlain).to.equal(body); + expect(sendFake.firstCall.firstArg.subject).to.equal(`Audit report generation has failed for Test Tenant`); + expect(sendFake.firstCall.firstArg.text).to.equal(body); expect(sendFake.firstCall.firstArg.toAddress).to.equal(user.email); + expect(sendFake.firstCall.firstArg.fromName).to.equal('USDR ARPA Reporter'); expect(sendFake.firstCall.firstArg.ccAddress).to.equal('grants-helpdesk@usdigitalresponse.org'); - expect(sendFake.firstCall.firstArg.emailHTML).contains(body); + expect(sendFake.firstCall.firstArg.body).contains(body); + // Not an actual user so no user tags + expect(sendFake.firstCall.firstArg.tags).to.deep.equal([ + 'notification_type=audit_report_error', + ]); }); }); - context('grant digest email', () => { + context('send grant digest email', () => { beforeEach(async () => { - this.clockFn = (date) => sinon.useFakeTimers(new Date(date)); + // Set to given date in local time zone + this.clockFn = (date) => sinon.useFakeTimers(moment(date).toDate()); this.clock = this.clockFn('2021-08-06'); }); afterEach(async () => { @@ -342,45 +364,33 @@ describe('Email sender', () => { }); it('sendGrantDigest sends no email when there are no grants to send', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); const agencies = await db.getAgency(0); const agency = agencies[0]; agency.matched_grants = []; - agency.recipients = ['foo@example.com']; + agency.recipient = 'foo@example.com'; - await email.sendGrantDigest({ + await email.sendGrantDigestEmail({ name: agency.name, - recipients: agency.recipients, + recipient: agency.recipient, matchedGrants: agency.matched_grants, openDate: moment().subtract(1, 'day').format('YYYY-MM-DD'), }); expect(sendFake.called).to.equal(false); }); - it('sendGrantDigest sends email to all users when there are grants', async () => { - const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); - + }); + context('build grant digest body', () => { + it('builds all the grants if fewer than 3 available', async () => { const agencies = await db.getAgency(fixtures.agencies.accountancy.id); const agency = agencies[0]; - agency.matched_grants = [fixtures.grants.healthAide]; - agency.recipients = [fixtures.users.adminUser.email, fixtures.users.staffUser.email]; - await email.sendGrantDigest({ - name: agency.name, - recipients: agency.recipients, + const body = await email.buildDigestBody({ + name: 'Saved search test', + openDate: '2021-08-05', matchedGrants: agency.matched_grants, - penDate: moment().subtract(1, 'day').format('YYYY-MM-DD'), }); - - expect(sendFake.calledTwice).to.equal(true); - }); - it('builds all the grants if fewer than 3 available', async () => { - const agencies = await db.getAgency(fixtures.agencies.accountancy.id); - const agency = agencies[0]; - agency.matched_grants = [fixtures.grants.healthAide]; - const body = await email.buildDigestBody({ name: 'Saved search test', openDate: '2021-08-05', matchedGrants: agency.matched_grants }); expect(body).to.include(fixtures.grants.healthAide.description); }); it('builds only first 3 grants if >3 available', async () => { @@ -414,7 +424,11 @@ describe('Email sender', () => { const agencies = await db.getAgency(fixtures.agencies.accountancy.id); const agency = agencies[0]; agency.matched_grants = [fixtures.grants.healthAide]; - const body = await email.buildDigestBody({ name: 'Saved search test', openDate: '2021-08-05', matchedGrants: agency.matched_grants }); + const body = await email.buildDigestBody({ + name: 'Saved search test', + openDate: '2021-08-05', + matchedGrants: agency.matched_grants, + }); expect(body).to.include(`https://www.grants.gov/search-results-detail/${fixtures.grants.healthAide.grant_id}`); }); it('links to Grant Finder when Grant Details page is live', async () => { @@ -422,46 +436,43 @@ describe('Email sender', () => { const agencies = await db.getAgency(fixtures.agencies.accountancy.id); const agency = agencies[0]; agency.matched_grants = [fixtures.grants.healthAide]; - const body = await email.buildDigestBody({ name: 'Saved search test', openDate: '2021-08-05', matchedGrants: agency.matched_grants }); + const body = await email.buildDigestBody({ + name: 'Saved search test', + openDate: '2021-08-05', + matchedGrants: agency.matched_grants, + }); expect(body).to.include(`${process.env.WEBSITE_DOMAIN}/grants/${fixtures.grants.healthAide.grant_id}`); }); }); - context('getAndSendGrantForSavedSearch', () => { - it('Sends an email for a saved search', async () => { - const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); - - const userSavedSearch = { - name: 'TestSavedSearch', - tenantId: 0, - email: 'foo@bar.com', - criteria: '{"includeKeywords":"interestedGrant"}', - }; - await email.getAndSendGrantForSavedSearch({ userSavedSearch, openDate: '2021-08-05' }); - - expect(sendFake.calledOnce).to.equal(true); - }); - }); - context('buildAndSendUserSavedSearchGrantDigest', () => { + context('buildAndSendGrantDigestEmails', () => { beforeEach(async () => { - this.clockFn = (date) => sinon.useFakeTimers(new Date(date)); + // Set to given date in local time zone + this.clockFn = (date) => sinon.useFakeTimers(moment(date).toDate()); this.clock = this.clockFn('2021-08-06'); }); afterEach(async () => { this.clock.restore(); }); - it('Sends an email for a saved search', async () => { + it('Sends an email for a user\'s saved search', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - await email.buildAndSendUserSavedSearchGrantDigest(1, '2021-08-05'); + // Build digest for adminUser who has 1 saved search + await email.buildAndSendGrantDigestEmails(1, '2021-08-05'); expect(sendFake.calledOnce).to.equal(true); + expect(_.omit(sendFake.firstCall.args[0], ['body', 'text'])).to.deep.equal({ + fromName: 'USDR Federal Grant Finder', + ccAddress: undefined, + toAddress: 'admin.user@test.com', + subject: 'New Grants Published for Simple 2 result search based on included keywords', + tags: ['notification_type=grant_digest', 'user_role=admin', 'organization_id=0', 'team_id=0'], + }); }); - it('Sends an email for a saved search', async () => { + it('Sends an email for all users\' saved searches', async () => { const sendFake = sinon.fake.returns('foo'); - sinon.replace(email, 'deliverEmail', sendFake); + sinon.replace(emailService, 'getTransport', sinon.fake.returns({ sendEmail: sendFake })); - await email.buildAndSendUserSavedSearchGrantDigest(); + await email.buildAndSendGrantDigestEmails(); expect(sendFake.calledOnce).to.equal(true); }); }); diff --git a/packages/server/__tests__/scripts/sendGrantDigestEmail.test.js b/packages/server/__tests__/scripts/sendGrantDigestEmail.test.js index ab60afa8b..a83556f8b 100644 --- a/packages/server/__tests__/scripts/sendGrantDigestEmail.test.js +++ b/packages/server/__tests__/scripts/sendGrantDigestEmail.test.js @@ -5,13 +5,13 @@ const sendGrantDigestEmail = require('../../src/scripts/sendGrantDigestEmail').m describe('sendGrantDigestEmail script', () => { const sandbox = sinon.createSandbox(); - let buildAndSendUserSavedSearchGrantDigestFake; + let buildAndSendGrantDigestEmailsFake; beforeEach(() => { process.env.ENABLE_GRANT_DIGEST_SCHEDULED_TASK = 'true'; process.env.ENABLE_SAVED_SEARCH_GRANTS_DIGEST = 'true'; - buildAndSendUserSavedSearchGrantDigestFake = sandbox.fake(); - sandbox.replace(email, 'buildAndSendUserSavedSearchGrantDigest', buildAndSendUserSavedSearchGrantDigestFake); + buildAndSendGrantDigestEmailsFake = sandbox.fake(); + sandbox.replace(email, 'buildAndSendGrantDigestEmails', buildAndSendGrantDigestEmailsFake); }); afterEach(() => { @@ -20,18 +20,18 @@ describe('sendGrantDigestEmail script', () => { it('triggers sending digest emails when flags are on', async () => { await sendGrantDigestEmail(); - expect(buildAndSendUserSavedSearchGrantDigestFake.called).to.equal(true); + expect(buildAndSendGrantDigestEmailsFake.called).to.equal(true); }); it('triggers no emails when scheduled task flag is off', async () => { process.env.ENABLE_GRANT_DIGEST_SCHEDULED_TASK = 'false'; await sendGrantDigestEmail(); - expect(buildAndSendUserSavedSearchGrantDigestFake.called).to.equal(false); + expect(buildAndSendGrantDigestEmailsFake.called).to.equal(false); }); it('skips buildAndSendUserSavedSearchGrantDigest when that email flag is off', async () => { process.env.ENABLE_SAVED_SEARCH_GRANTS_DIGEST = 'false'; await sendGrantDigestEmail(); - expect(buildAndSendUserSavedSearchGrantDigestFake.called).to.equal(false); + expect(buildAndSendGrantDigestEmailsFake.called).to.equal(false); }); }); diff --git a/packages/server/src/arpa_reporter/lib/audit-report.js b/packages/server/src/arpa_reporter/lib/audit-report.js index 4676b0419..99915d318 100644 --- a/packages/server/src/arpa_reporter/lib/audit-report.js +++ b/packages/server/src/arpa_reporter/lib/audit-report.js @@ -748,7 +748,7 @@ async function processSQSMessageRequest(message) { await generateAndSendEmail(ARPA_REPORTER_BASE_URL, user.email, user.tenant_id, requestData.periodId); } catch (err) { log.error({ err }, 'failed to generate and send audit report'); - await email.sendReportErrorEmail(user, 'Audit'); + await email.sendReportErrorEmail(user, email.ASYNC_REPORT_TYPES.audit); return false; } diff --git a/packages/server/src/arpa_reporter/services/generate-arpa-report.js b/packages/server/src/arpa_reporter/services/generate-arpa-report.js index 00c05c0e2..6dc84d7aa 100644 --- a/packages/server/src/arpa_reporter/services/generate-arpa-report.js +++ b/packages/server/src/arpa_reporter/services/generate-arpa-report.js @@ -1129,7 +1129,7 @@ async function processSQSMessageRequest(message) { await generateAndSendEmail(user.email, requestData.periodId, requestData.tenantId); } catch (err) { log.error({ err }, 'failed to generate and send treasury report'); - await email.sendReportErrorEmail(user, 'Treasury'); + await email.sendReportErrorEmail(user, email.ASYNC_REPORT_TYPES.treasury); return false; } diff --git a/packages/server/src/db/index.js b/packages/server/src/db/index.js index 6774ed43f..0ff8cccb3 100755 --- a/packages/server/src/db/index.js +++ b/packages/server/src/db/index.js @@ -188,6 +188,13 @@ async function getUser(id) { return user; } +async function getUserIdForEmail(email) { + const [user] = await knex('users') + .select('users.id') + .where('email', email); + return user ? user.id : null; +} + async function getAgencyCriteriaForAgency(agencyId) { const eligibilityCodes = await getAgencyEligibilityCodes(agencyId); const enabledECodes = eligibilityCodes.filter((e) => e.enabled); @@ -1667,6 +1674,7 @@ module.exports = { getSubscribersForNotification, getUsersEmailAndName, getUser, + getUserIdForEmail, getAgencyCriteriaForAgency, isSubOrganization, getRoles, diff --git a/packages/server/src/index.js b/packages/server/src/index.js index 29283ac87..3c3d234f7 100755 --- a/packages/server/src/index.js +++ b/packages/server/src/index.js @@ -30,7 +30,7 @@ if (process.env.ENABLE_SAVED_SEARCH_GRANTS_DIGEST === 'true' && process.env.ENAB const generateSavedSearchGrantDigestCron = new CronJob( // once per day at 13:00 UTC // one hour after the old grant digest - '0 0 13 * * *', emailService.buildAndSendUserSavedSearchGrantDigest, + '0 0 13 * * *', emailService.buildAndSendGrantDigestEmails, ); generateSavedSearchGrantDigestCron.start(); } diff --git a/packages/server/src/lib/access-helpers.js b/packages/server/src/lib/access-helpers.js index 9fd267457..baca36150 100755 --- a/packages/server/src/lib/access-helpers.js +++ b/packages/server/src/lib/access-helpers.js @@ -4,11 +4,16 @@ const { log } = require('./logging'); const USDR_TENANT_ID = 1; const USDR_AGENCY_ID = 0; const USDR_EMAIL_DOMAIN = 'usdigitalresponse.org'; + +function isUSDR(user) { + return user.tenant_id === USDR_TENANT_ID; +} + function isUSDRSuperAdmin(user) { // Note: this function assumes an augmented user object from db.getUser(), not just a raw DB row // (necessary for role_name field) return ( - user.tenant_id === USDR_TENANT_ID + isUSDR(user) && user.agency_id === USDR_AGENCY_ID && user.role_name === 'admin' // TODO: Right now there are a bunch of non-USDR users in USDR tenant in prod, so we need to @@ -142,5 +147,5 @@ async function isMicrosoftSafeLinksRequest(req, res, next) { } module.exports = { - requireAdminUser, requireUser, isAuthorizedForAgency, isUserAuthorized, isUSDRSuperAdmin, requireUSDRSuperAdminUser, getAdminAuthInfo, isMicrosoftSafeLinksRequest, + requireAdminUser, requireUser, isAuthorizedForAgency, isUserAuthorized, isUSDR, isUSDRSuperAdmin, requireUSDRSuperAdminUser, getAdminAuthInfo, isMicrosoftSafeLinksRequest, }; diff --git a/packages/server/src/lib/email.js b/packages/server/src/lib/email.js index 424e993cc..0e8b1f59a 100644 --- a/packages/server/src/lib/email.js +++ b/packages/server/src/lib/email.js @@ -1,5 +1,6 @@ const { URL } = require('url'); const moment = require('moment'); +const { capitalize } = require('lodash'); // eslint-disable-next-line import/no-unresolved const asyncBatch = require('async-batch').default; const fileSystem = require('fs'); @@ -8,18 +9,34 @@ const mustache = require('mustache'); const { log } = require('./logging'); const emailService = require('./email/service-email'); const db = require('../db'); -const { notificationType } = require('./email/constants'); +const { notificationType, tags } = require('./email/constants'); +const { isUSDR, isUSDRSuperAdmin } = require('./access-helpers'); const expiryMinutes = 30; const ASYNC_REPORT_TYPES = { - audit: 'audit', - treasury: 'treasury', + audit: { + name: 'audit', + emailType: tags.emailTypes.auditReport, + errorEmailType: tags.emailTypes.auditReportError, + }, + treasury: { + name: 'treasury', + emailType: tags.emailTypes.treasuryReport, + errorEmailType: tags.emailTypes.treasuryReportError, + }, }; const HELPDESK_EMAIL = 'grants-helpdesk@usdigitalresponse.org'; const GENERIC_FROM_NAME = 'USDR Grants'; const GRANT_FINDER_EMAIL_FROM_NAME = 'USDR Federal Grant Finder'; const ARPA_EMAIL_FROM_NAME = 'USDR ARPA Reporter'; +function getUserRoleTag(user) { + if (isUSDRSuperAdmin(user)) { + return 'usdr_super_admin'; + } + return `${isUSDR(user) ? 'usdr_' : ''}${user.role_name}`; +} + async function deliverEmail({ fromName, toAddress, @@ -27,7 +44,19 @@ async function deliverEmail({ emailHTML, emailPlain, subject, + emailType, }) { + let userTags = []; + const recipientId = await db.getUserIdForEmail(toAddress); + if (recipientId) { + const recipient = await db.getUser(recipientId); + userTags = [ + `user_role=${getUserRoleTag(recipient)}`, + `organization_id=${recipient.tenant_id}`, + `team_id=${recipient.agency_id}`, + ]; + } + return emailService.getTransport().sendEmail({ fromName, toAddress, @@ -35,6 +64,10 @@ async function deliverEmail({ subject, body: emailHTML, text: emailPlain, + tags: [ + `notification_type=${emailType}`, + ...userTags, + ], }); } @@ -94,9 +127,9 @@ function addBaseBranding(emailHTML, brandDetails) { return brandedHTML; } -async function sendPassCode(email, passcode, httpOrigin, redirectTo) { +async function sendPassCodeEmail(email, passcode, httpOrigin, redirectTo) { if (!httpOrigin) { - throw new Error('must specify httpOrigin in sendPassCode'); + throw new Error('must specify httpOrigin in sendPassCodeEmail'); } const url = new URL(`${httpOrigin}/api/sessions`); @@ -114,7 +147,7 @@ async function sendPassCode(email, passcode, httpOrigin, redirectTo) { It expires in ${expiryMinutes} minutes

`, }); - const emailHTML = module.exports.addBaseBranding( + const emailHTML = addBaseBranding( formattedBody, { tool_name: href.includes('reporter') ? 'Grants Reporter Tool' : 'Grants Identification Tool', @@ -130,20 +163,21 @@ async function sendPassCode(email, passcode, httpOrigin, redirectTo) { console.log(`${BLUE}${message}`); console.log(`${BLUE}${'-'.repeat(message.length)}`); } - await module.exports.deliverEmail({ + await deliverEmail({ fromName: GENERIC_FROM_NAME, toAddress: email, emailHTML, emailPlain: `Your link to access USDR's Grants tool is ${href}. It expires in ${expiryMinutes} minutes`, subject: 'USDR Grants Tool Access Link', + emailType: tags.emailTypes.passcode, }); } async function sendReportErrorEmail(user, reportType) { - const body = `There was an error generating a your requested ${reportType} Report. ` + const body = `There was an error generating your requested ${reportType.name} report. ` + 'Someone from USDR will reach out within 24 hours to debug the problem. ' + 'We apologize for any inconvenience.'; - const subject = `${reportType} Report generation has failed for ${user.tenant.display_name}`; + const subject = `${capitalize(reportType.name)} report generation has failed for ${user.tenant.display_name}`; const formattedBodyTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_formatted_body.html')); @@ -152,7 +186,7 @@ async function sendReportErrorEmail(user, reportType) { body_detail: body, }); - const emailHTML = module.exports.addBaseBranding( + const emailHTML = addBaseBranding( formattedBody, { tool_name: 'Grants Reporter Tool', @@ -161,13 +195,14 @@ async function sendReportErrorEmail(user, reportType) { }, ); - await module.exports.deliverEmail({ + await deliverEmail({ fromName: ARPA_EMAIL_FROM_NAME, toAddress: user.email, ccAddress: HELPDESK_EMAIL, emailHTML, emailPlain: body, subject, + emailType: reportType.errorEmailType, }); } @@ -183,7 +218,7 @@ function sendWelcomeEmail(email, httpOrigin) { ${httpOrigin}.`, }); - const emailHTML = module.exports.addBaseBranding( + const emailHTML = addBaseBranding( formattedBody, { tool_name: httpOrigin.includes('reporter') ? 'Grants Reporter Tool' : 'Grants Identification Tool', @@ -192,12 +227,13 @@ function sendWelcomeEmail(email, httpOrigin) { }, ); - return module.exports.deliverEmail({ + return deliverEmail({ fromName: GENERIC_FROM_NAME, toAddress: email, emailHTML, emailPlain: `Visit USDR's Grants Tool at: ${httpOrigin}.`, subject: 'Welcome to USDR Grants Tool', + emailType: tags.emailTypes.welcome, }); } @@ -255,11 +291,11 @@ function getGrantDetail(grant, emailNotificationType) { async function buildGrantDetail(grantId, emailNotificationType) { // Add try catch here. const grant = await db.getGrant({ grantId }); - const grantDetail = module.exports.getGrantDetail(grant, emailNotificationType); + const grantDetail = getGrantDetail(grant, emailNotificationType); return grantDetail; } -async function sendGrantAssignedNotficationForAgency(assignee_agency, grantDetail, assignorUserId) { +async function sendGrantAssignedEmailsForAgency(assignee_agency, grantDetail, assignorUserId) { const grantAssignedBodyTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_grant_assigned_body.html')); const assignor = await db.getUser(assignorUserId); @@ -276,7 +312,7 @@ async function sendGrantAssignedNotficationForAgency(assignee_agency, grantDetai baseUrl.searchParams.set('utm_source', 'subscription'); baseUrl.searchParams.set('utm_medium', 'email'); baseUrl.searchParams.set('utm_campaign', 'GRANT_ASSIGNMENT'); - const emailHTML = module.exports.addBaseBranding(grantAssignedBody, { + const emailHTML = addBaseBranding(grantAssignedBody, { tool_name: 'Grants Identification Tool', title: 'Grants Assigned Notification', includeNotificationsLink: true, @@ -295,12 +331,13 @@ async function sendGrantAssignedNotficationForAgency(assignee_agency, grantDetai emailHTML, emailPlain, subject: emailSubject, + emailType: tags.emailTypes.grantAssignment, }, )); - await asyncBatch(inputs, module.exports.deliverEmail, 2); + await asyncBatch(inputs, deliverEmail, 2); } -async function sendGrantAssignedEmail({ grantId, agencyIds, userId }) { +async function sendGrantAssignedEmails({ grantId, agencyIds, userId }) { /* 1. Build the grant detail template 2. For each agency @@ -313,7 +350,7 @@ async function sendGrantAssignedEmail({ grantId, agencyIds, userId }) { const agencies = await db.getAgenciesByIds(agencyIds); await asyncBatch( agencies, - async (agency) => { await module.exports.sendGrantAssignedNotficationForAgency(agency, grantDetail, userId); }, + async (agency) => { await sendGrantAssignedEmailsForAgency(agency, grantDetail, userId); }, 2, ); } catch (err) { @@ -326,7 +363,7 @@ async function sendGrantAssignedEmail({ grantId, agencyIds, userId }) { async function buildDigestBody({ name, openDate, matchedGrants }) { const grantDetails = []; - matchedGrants.slice(0, 30).forEach((grant) => grantDetails.push(module.exports.getGrantDetail(grant, notificationType.grantDigest))); + matchedGrants.slice(0, 30).forEach((grant) => grantDetails.push(getGrantDetail(grant, notificationType.grantDigest))); const formattedBodyTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_formatted_body.html')); const contentSpacerTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_content_spacer.html')); @@ -346,8 +383,8 @@ async function buildDigestBody({ name, openDate, matchedGrants }) { return formattedBody; } -async function sendGrantDigest({ - name, matchedGrants, matchedGrantsTotal, recipients, openDate, +async function sendGrantDigestEmail({ + name, matchedGrants, matchedGrantsTotal, recipient, openDate, }) { console.log(`${name} is subscribed for notifications on ${openDate}`); @@ -356,17 +393,12 @@ async function sendGrantDigest({ return; } - if (!recipients || recipients?.length === 0) { - console.error(`There were no email recipients available for ${name}`); - return; - } - const formattedBody = await buildDigestBody({ name, openDate, matchedGrants }); const preheader = typeof matchedGrantsTotal === 'number' && matchedGrantsTotal > 0 ? `You have ${Intl.NumberFormat('en-US', { useGrouping: true }).format(matchedGrantsTotal)} new ${matchedGrantsTotal > 1 ? 'grants' : 'grant'} to review!` : 'You have new grants to review!'; - const emailHTML = module.exports.addBaseBranding(formattedBody, { + const emailHTML = addBaseBranding(formattedBody, { tool_name: 'Federal Grant Finder', title: 'New Grants Digest', preheader, @@ -376,19 +408,16 @@ async function sendGrantDigest({ // TODO: add plain text version of the email const emailPlain = emailHTML.replace(/<[^>]+>/g, ''); - const inputs = []; - recipients.forEach( - (recipient) => inputs.push( - { - fromName: GRANT_FINDER_EMAIL_FROM_NAME, - toAddress: recipient.trim(), - emailHTML, - emailPlain, - subject: `New Grants Published for ${name}`, - }, - ), + await deliverEmail( + { + fromName: GRANT_FINDER_EMAIL_FROM_NAME, + toAddress: recipient, + emailHTML, + emailPlain, + subject: `New Grants Published for ${name}`, + emailType: tags.emailTypes.grantDigest, + }, ); - await asyncBatch(inputs, module.exports.deliverEmail, 2); } async function getAndSendGrantForSavedSearch({ @@ -408,19 +437,20 @@ async function getAndSendGrantForSavedSearch({ false, ); - return sendGrantDigest({ + return sendGrantDigestEmail({ name: userSavedSearch.name, matchedGrants: response.data, matchedGrantsTotal: response.pagination.total, - recipients: [userSavedSearch.email], + recipient: userSavedSearch.email, openDate, }); } -async function buildAndSendUserSavedSearchGrantDigest(userId, openDate) { - if (!openDate) { - openDate = moment().subtract(1, 'day').format('YYYY-MM-DD'); - } +function yesterday() { + return moment().subtract(1, 'day').format('YYYY-MM-DD'); +} + +async function buildAndSendGrantDigestEmails(userId, openDate = yesterday()) { console.log(`Building and sending Grants Digest email for user: ${userId} on ${openDate}`); /* 1. get all saved searches mapped to each user @@ -445,41 +475,43 @@ async function sendAsyncReportEmail(recipient, signedUrl, reportType) { const formattedBodyTemplate = fileSystem.readFileSync(path.join(__dirname, '../static/email_templates/_formatted_body.html')); const formattedBody = mustache.render(formattedBodyTemplate.toString(), { - body_title: `Your ${reportType} report is ready for download`, + body_title: `Your ${reportType.name} report is ready for download`, body_detail: `

Click here to download your file
Or, paste this link into your browser:
${signedUrl}

This link will remain active for 7 days.

`, }); - const emailHTML = module.exports.addBaseBranding( + const emailHTML = addBaseBranding( formattedBody, { tool_name: 'Grants Reporter Tool', - title: `Your ${reportType} report is ready for download`, + title: `Your ${reportType.name} report is ready for download`, includeNotificationsLink: false, }, ); - await module.exports.deliverEmail({ + await deliverEmail({ fromName: ARPA_EMAIL_FROM_NAME, toAddress: recipient, emailHTML, - emailPlain: `Your ${reportType} report is ready for download. Paste this link into your browser to download it: ${signedUrl} This link will remain active for 7 days.`, - subject: `Your ${reportType} report is ready for download`, + emailPlain: `Your ${reportType.name} report is ready for download. Paste this link into your browser to download it: ${signedUrl} This link will remain active for 7 days.`, + subject: `Your ${reportType.name} report is ready for download`, + emailType: reportType.emailType, }); } module.exports = { - sendPassCode, + sendPassCodeEmail, sendWelcomeEmail, sendReportErrorEmail, - sendGrantAssignedEmail, - deliverEmail, - buildGrantDetail, - sendGrantAssignedNotficationForAgency, - buildAndSendUserSavedSearchGrantDigest, - getAndSendGrantForSavedSearch, - sendGrantDigest, - getGrantDetail, - addBaseBranding, + /** + * Send emails to all subscribed parties when a grant is assigned to one or more agencies. + */ + sendGrantAssignedEmails, + /** + * Send grant digest emails to all subscribed users. + */ + buildAndSendGrantDigestEmails, + sendGrantDigestEmail, + // Exposed for testing buildDigestBody, sendAsyncReportEmail, ASYNC_REPORT_TYPES, diff --git a/packages/server/src/lib/email/constants.js b/packages/server/src/lib/email/constants.js index 5f958348d..30cd08911 100644 --- a/packages/server/src/lib/email/constants.js +++ b/packages/server/src/lib/email/constants.js @@ -1,3 +1,4 @@ +// Types of emails that a user may subscribe to const notificationType = Object.freeze({ grantAssignment: 'GRANT_ASSIGNMENT', grantInterest: 'GRANT_INTEREST', @@ -18,4 +19,21 @@ const defaultSubscriptionPreference = Object.freeze( ), ); -module.exports = { notificationType, emailSubscriptionStatus, defaultSubscriptionPreference }; +const tags = Object.freeze( + { + emailTypes: { + passcode: 'passcode', + grantAssignment: 'grant_assignment', + auditReport: 'audit_report', + treasuryReport: 'treasury_report', + welcome: 'welcome', + grantDigest: 'grant_digest', + treasuryReportError: 'treasury_report_error', + auditReportError: 'audit_report_error', + }, + }, +); + +module.exports = { + notificationType, emailSubscriptionStatus, defaultSubscriptionPreference, tags, +}; diff --git a/packages/server/src/lib/email/email-nodemailer.js b/packages/server/src/lib/email/email-nodemailer.js index 417247d7d..95b97bf71 100644 --- a/packages/server/src/lib/email/email-nodemailer.js +++ b/packages/server/src/lib/email/email-nodemailer.js @@ -54,6 +54,11 @@ async function sendEmail(message) { subject: message.subject, // text: 'Hello world?', // plain text body html: message.body, // html body + headers: { + // This is the correct header for tags if sending to an AWS SES SMTP endpoint. + // Any other SMTP server will probably ignore this header. + 'X-SES-MESSAGE-TAGS': message.tags.join(', '), + }, }; if (message.ccAddress) { params.cc = message.ccAddress; diff --git a/packages/server/src/lib/gost-aws.js b/packages/server/src/lib/gost-aws.js index 4caba23cc..7d25bfd28 100644 --- a/packages/server/src/lib/gost-aws.js +++ b/packages/server/src/lib/gost-aws.js @@ -87,6 +87,18 @@ async function sendEmail(message) { }, }, }, + Tags: message.tags.map((tag) => { + // Tags must be strings of format 'name=value' + const match = tag.match(/(?\w+)=(?\w+)/); + if (!match) { + return null; + } + + return { + Name: match.groups.name, + Value: match.groups.value, + }; + }).filter((tagObj) => !!tagObj), }; if (message.ccAddress) { params.Destination.CcAddresses = [message.ccAddress]; diff --git a/packages/server/src/routes/grants.js b/packages/server/src/routes/grants.js index fcb81502c..85af01556 100755 --- a/packages/server/src/routes/grants.js +++ b/packages/server/src/routes/grants.js @@ -361,7 +361,7 @@ router.put('/:grantId/assign/agencies', requireUser, async (req, res) => { await db.assignGrantsToAgencies({ grantId, agencyIds, userId: user.id }); try { - await email.sendGrantAssignedEmail({ grantId, agencyIds, userId: user.id }); + await email.sendGrantAssignedEmails({ grantId, agencyIds, userId: user.id }); } catch { res.sendStatus(500); return; diff --git a/packages/server/src/routes/sessions.js b/packages/server/src/routes/sessions.js index 882331c14..e6b03ce88 100755 --- a/packages/server/src/routes/sessions.js +++ b/packages/server/src/routes/sessions.js @@ -1,7 +1,7 @@ const express = require('express'); const _ = require('lodash-checkit'); const path = require('path'); -const { sendPassCode } = require('../lib/email'); +const { sendPassCodeEmail } = require('../lib/email'); const { validatePostLoginRedirectPath } = require('../lib/redirect_validation'); const { isMicrosoftSafeLinksRequest } = require('../lib/access-helpers'); @@ -88,7 +88,7 @@ router.post('/', async (req, res, next) => { const passcode = await createAccessToken(email); const domain = process.env.API_DOMAIN || process.env.WEBSITE_DOMAIN || req.headers.origin; const redirectTo = validatePostLoginRedirectPath(req.body.redirect_to); - await sendPassCode(email, passcode, domain, redirectTo); + await sendPassCodeEmail(email, passcode, domain, redirectTo); res.json({ success: true, diff --git a/packages/server/src/routes/users.js b/packages/server/src/routes/users.js index f483144a7..e8b6efc03 100755 --- a/packages/server/src/routes/users.js +++ b/packages/server/src/routes/users.js @@ -126,9 +126,9 @@ router.get('/:userId/sendDigestEmail', requireUSDRSuperAdminUser, async (req, re } try { - await email.buildAndSendUserSavedSearchGrantDigest( + await email.buildAndSendGrantDigestEmails( user.id, - req.query.date ? moment(new Date(req.query.date)).format('YYYY-MM-DD') : undefined, + req.query.date ? moment(req.query.date).format('YYYY-MM-DD') : undefined, ); } catch (e) { console.error(`Unable to kick-off digest email for user '${user.id}' due to error '${e}' stack: ${e.stack}`); diff --git a/packages/server/src/scripts/sendDebugEmail.js b/packages/server/src/scripts/sendDebugEmail.js index 410e3474f..0181eb218 100644 --- a/packages/server/src/scripts/sendDebugEmail.js +++ b/packages/server/src/scripts/sendDebugEmail.js @@ -17,7 +17,7 @@ async function sendWelcome() { async function sendPassCode() { const loginEmail = 'admin@example.com'; const passcode = await db.createAccessToken(loginEmail); - await email.sendPassCode( + await email.sendPassCodeEmail( loginEmail, passcode, process.env.WEBSITE_DOMAIN, @@ -28,11 +28,11 @@ async function sendPassCode() { async function sendGrantDigest() { const grantIds = seedGrants.grants.slice(0, 3).map((grant) => grant.grant_id); const grants = await knex(TABLES.grants).whereIn('grant_id', grantIds); - await email.sendGrantDigest({ + await email.sendGrantDigestEmail({ name: 'Test agency', matchedGrants: grants, matchedGrantsTotal: grantIds.length, - recipients: ['test@example.com'], + recipient: 'test@example.com', openDate: '2024-01-01', }); } @@ -40,25 +40,39 @@ async function sendGrantDigest() { async function sendGrantAssigned() { // Use Dallas since there's only one user in the agency, so we should get only 1 email sent const user = seedUsers.find((seedUser) => seedUser.email === 'user1@dallas.gov'); - await email.sendGrantAssignedEmail({ + await email.sendGrantAssignedEmails({ grantId: seedGrants.grants[0].grant_id, agencyIds: [user.agency_id], userId: user.id, }); } -async function sendAsyncReport() { +async function sendAuditReport() { await email.sendAsyncReportEmail( 'test@example.com', `${process.env.API_DOMAIN}/api/audit_report/fake_key`, - 'Audit', + email.ASYNC_REPORT_TYPES.audit, ); } -async function sendReportError() { +async function sendTreasuryReport() { + await email.sendAsyncReportEmail( + 'test@example.com', + `${process.env.API_DOMAIN}/api/treasury_report/fake_key`, + email.ASYNC_REPORT_TYPES.treasury, + ); +} + +async function sendAuditReportError() { + const userId = seedUsers.find((seedUser) => seedUser.email === 'admin@example.com').id; + const user = await db.getUser(userId); + await email.sendReportErrorEmail(user, email.ASYNC_REPORT_TYPES.audit); +} + +async function sendTreasuryReportError() { const userId = seedUsers.find((seedUser) => seedUser.email === 'admin@example.com').id; const user = await db.getUser(userId); - await email.sendReportErrorEmail(user, 'Audit'); + await email.sendReportErrorEmail(user, email.ASYNC_REPORT_TYPES.treasury); } const emailTypes = { @@ -66,8 +80,10 @@ const emailTypes = { 'login passcode': sendPassCode, 'grant digest': sendGrantDigest, 'grant assigned': sendGrantAssigned, - 'report generation completed': sendAsyncReport, - 'report generation failed': sendReportError, + 'audit report generation completed': sendAuditReport, + 'treasury report generation completed': sendTreasuryReport, + 'audit report generation failed': sendAuditReportError, + 'treasury report generation failed': sendTreasuryReportError, }; async function main() { diff --git a/packages/server/src/scripts/sendGrantDigestEmail.js b/packages/server/src/scripts/sendGrantDigestEmail.js index 02e5d99ec..882bd1701 100644 --- a/packages/server/src/scripts/sendGrantDigestEmail.js +++ b/packages/server/src/scripts/sendGrantDigestEmail.js @@ -19,7 +19,7 @@ exports.main = async function main() { await tracer.trace('arpaTreasuryReport', async () => { if (process.env.ENABLE_SAVED_SEARCH_GRANTS_DIGEST === 'true') { log.info('Sending saved search grant digest emails'); - await email.buildAndSendUserSavedSearchGrantDigest(); + await email.buildAndSendGrantDigestEmails(); } }); };