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();
}
});
};