Skip to content

Commit

Permalink
feat: create form to send email to all site collaborators (#1368)
Browse files Browse the repository at this point in the history
* feat: create form to send email to all site collaborators

* chore: remove extra console.log statement

* chore: update error message
  • Loading branch information
dcshzj authored and seaerchin committed May 27, 2024
1 parent 1405163 commit b0428e0
Show file tree
Hide file tree
Showing 12 changed files with 467 additions and 7 deletions.
4 changes: 4 additions & 0 deletions .aws/deploy/backend-task-definition.prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@
{
"name": "SITE_AUDIT_LOGS_FORM_KEY",
"valueFrom": "PROD_SITE_AUDIT_LOGS_FORM_KEY"
},
{
"name": "NOTIFY_SITE_COLLABORATORS_FORM_KEY",
"valueFrom": "PROD_NOTIFY_SITE_COLLABORATORS_FORM_KEY"
}
],
"logConfiguration": {
Expand Down
4 changes: 4 additions & 0 deletions .aws/deploy/backend-task-definition.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,10 @@
{
"name": "SITE_AUDIT_LOGS_FORM_KEY",
"valueFrom": "STAGING_SITE_AUDIT_LOGS_FORM_KEY"
},
{
"name": "NOTIFY_SITE_COLLABORATORS_FORM_KEY",
"valueFrom": "STAGING_NOTIFY_SITE_COLLABORATORS_FORM_KEY"
}
],
"logConfiguration": {
Expand Down
4 changes: 4 additions & 0 deletions .aws/deploy/support-task-definition.prod.json
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@
{
"name": "SITE_AUDIT_LOGS_FORM_KEY",
"valueFrom": "PROD_SITE_AUDIT_LOGS_FORM_KEY"
},
{
"name": "NOTIFY_SITE_COLLABORATORS_FORM_KEY",
"valueFrom": "PROD_NOTIFY_SITE_COLLABORATORS_FORM_KEY"
}
],
"logConfiguration": {
Expand Down
4 changes: 4 additions & 0 deletions .aws/deploy/support-task-definition.staging.json
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,10 @@
{
"name": "SITE_AUDIT_LOGS_FORM_KEY",
"valueFrom": "STAGING_SITE_AUDIT_LOGS_FORM_KEY"
},
{
"name": "NOTIFY_SITE_COLLABORATORS_FORM_KEY",
"valueFrom": "STAGING_NOTIFY_SITE_COLLABORATORS_FORM_KEY"
}
],
"logConfiguration": {
Expand Down
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export SITE_LAUNCH_FORM_KEY="site_launch_form_key"
export GGS_REPAIR_FORM_KEY="ggs_repair_form_key"
export SITE_CHECKER_FORM_KEY="site_checker_form_key"
export SITE_AUDIT_LOGS_FORM_KEY="site_audit_logs_form_key"
export NOTIFY_SITE_COLLABORATORS_FORM_KEY="notify_site_collaborators_form_key"

# Required to connect to DynamoDB
export AWS_ACCESS_KEY_ID="abc123"
Expand Down
1 change: 1 addition & 0 deletions .platform/hooks/predeploy/06_fetch_ssm_parameters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ ENV_VARS=(
"SITE_CHECKER_FORM_KEY"
"SITE_AUDIT_LOGS_FORM_KEY"
"SLACK_SIGNING_SECRET"
"NOTIFY_SITE_COLLABORATORS_FORM_KEY"
)

echo "Set AWS region"
Expand Down
7 changes: 7 additions & 0 deletions src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,13 @@ const config = convict({
format: "required-string",
default: "",
},
notifySiteCollaboratorsFormKey: {
doc: "FormSG API key for notify site collaborators form",
env: "NOTIFY_SITE_COLLABORATORS_FORM_KEY",
sensitive: true,
format: "required-string",
default: "",
},
},
postman: {
apiKey: {
Expand Down
141 changes: 140 additions & 1 deletion src/services/identity/CollaboratorsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,16 @@ import { UnprocessableError } from "@errors/UnprocessableError"
import {
CollaboratorRoles,
INACTIVE_USER_THRESHOLD_DAYS,
ISOMER_SUPPORT_EMAIL,
} from "@constants/constants"

import { Whitelist, User, Site, SiteMember, Repo } from "@database/models"
import { Repo, Site, SiteMember, User, Whitelist } from "@database/models"
import { BadRequestError } from "@root/errors/BadRequestError"
import { ConflictError } from "@root/errors/ConflictError"
import logger from "@root/logger/logger"
import type { DNSRecord } from "@root/types/siteInfo"

import { mailer } from "../utilServices/MailClient"

import IsomerAdminsService from "./IsomerAdminsService"
import SitesService from "./SitesService"
Expand Down Expand Up @@ -316,6 +320,141 @@ class CollaboratorsService {
inactive: inactiveCount,
}
}

// Send email to all collaborators of given siteName
notify = async (
siteName: string,
subject: string,
body: string,
sender?: string
) => {
const collaborators = await this.list(siteName)

if (collaborators.length === 0) {
logger.warn({
message: "No collaborators found for site",
meta: {
siteName,
method: "notfify",
},
})
return new NotFoundError(`No collaborators found for site`)
}

// Send email to only the admins of the site and filter for only those with emails
const recipientEmails = _.orderBy(
collaborators,
[
// Prioritize Admins over Contributors
(collaborator) =>
collaborator.SiteMember.role === CollaboratorRoles.Admin,
// Prioritize the user that has most recently logged in
(collaborator) => collaborator.lastLoggedIn,
],
["desc", "desc"]
)
.filter(
(collaborator) =>
collaborator.SiteMember.role === CollaboratorRoles.Admin
)
.flatMap((collaborator) =>
collaborator.email ? [collaborator.email] : []
)

if (recipientEmails.length === 0) {
logger.warn({
message: "No email addresses found for site admins",
meta: {
siteName,
method: "notify",
},
})
return new NotFoundError(`No email addresses found for site admins`)
}

// CC the sender and Isomer Support
if (sender) {
recipientEmails.push(sender)
}
recipientEmails.push(ISOMER_SUPPORT_EMAIL)

// Send email to all admins of the site
return mailer.sendMailWithCc(
recipientEmails[0], // Limitation of Postman API - only one recipient allowed
"Isomer Team",
recipientEmails.slice(1),
ISOMER_SUPPORT_EMAIL,
subject,
body
)
}

// Send email to all collaborators of given siteName with DNS records
notifyWithDnsRecords = async (
type: "apex" | "main",
siteName: string,
domainName: string,
dnsRecords: DNSRecord[],
sender: string
) => {
const tableStyle =
"border-collapse: collapse; width: 100%; border: 1px solid #ddd; text-align: center;"

const thStyle = "padding: 8px; text-align: center; border: 1px solid #ddd;"

const tdStyle = "padding: 8px; text-align: left; border: 1px solid #ddd;"

const headerRowStyle =
"background-color: #f2f2f2; border: 1px solid #ddd; text-align: center;"

const subject = `[For Action] Incorrect DNS Configuration for ${domainName}`
const body = `<p>Admins of ${siteName} Isomer site,</p>
<p>We are from the Isomer team, and we have noticed that the DNS records for the ${domainName} domain have been incorrectly configured. This was flagged by our uptime monitoring service, which monitors the uptime of all Isomer websites.</p>
<p><b>What does this mean?</b></p>
<ul>
${
type === "apex"
? `<li>Should a user go to ${domainName}, they will not be redirected to www.${domainName}.</li>`
: ""
}
${
type === "main"
? `<li>Should a user go to ${domainName}, they will not be able to see your Isomer website.</li>`
: ""
}
<li>The user may potentially see other errors reported by their web browser.</li>
</ul>
<p><b>What can be done?</b></p>
<ul>
<li>Ensure that the DNS records for ${domainName} matches the ones provided in the following table, and does not contain any additional A or AAAA records:</li>
</ul>
<table style="${tableStyle}">
<thead>
<tr style="${headerRowStyle}">
<th style="${thStyle}">Source</th>
<th style="${thStyle}">Target</th>
<th style="${thStyle}">Type</th>
</tr>
</thead>
<tbody>
${dnsRecords
.map(
({ source, target, type }) =>
`<tr style="${tdStyle}">
<td style="${tdStyle}">${source}</td>
<td style="${tdStyle}">${target}</td>
<td style="${tdStyle}">${type === "A" ? "A Record" : type}</td>
</tr>`
)
.join("\n")}
</tbody>
</table>
<p>Please let us know once the DNS records have been updated by replying to this email, thank you.</p>
<p></p>
<p>Warmest regards,<br />Isomer Team</p>`

return this.notify(siteName, subject, body, sender)
}
}

export default CollaboratorsService
93 changes: 93 additions & 0 deletions src/services/identity/__tests__/CollaboratorsService.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,18 @@ import {
mockSiteOrmResponseWithOneAdminCollaborator,
mockSiteOrmResponseWithOneContributorCollaborator,
mockSiteOrmResponseWithNoCollaborators,
mockCollaboratorAdmin1,
mockCollaboratorAdmin2,
} from "@fixtures/identity"
import {
CollaboratorRoles,
CollaboratorRolesWithoutIsomerAdmin,
INACTIVE_USER_THRESHOLD_DAYS,
ISOMER_SUPPORT_EMAIL,
} from "@root/constants"
import { BadRequestError } from "@root/errors/BadRequestError"
import { ConflictError } from "@root/errors/ConflictError"
import { mailer } from "@root/services/utilServices/MailClient"
import CollaboratorsService from "@services/identity/CollaboratorsService"
import IsomerAdminsService from "@services/identity/IsomerAdminsService"
import SitesService from "@services/identity/SitesService"
Expand Down Expand Up @@ -558,4 +562,93 @@ describe("CollaboratorsService", () => {
expect(mockSiteRepo.findOne).toBeCalled()
})
})

describe("notify", () => {
it("should send an email to all site admins", async () => {
// Arrange
mockSiteRepo.findOne.mockResolvedValue(
mockSiteOrmResponseWithAllCollaborators
)
const spySendMailWithCc = jest.spyOn(mailer, "sendMailWithCc")
spySendMailWithCc.mockResolvedValueOnce()

// Act
await collaboratorsService.notify(
mockSiteName,
"subject",
"body",
mockEmailAddress
)

// Assert
expect(mockSiteRepo.findOne).toHaveBeenCalledOnce()
expect(spySendMailWithCc).toHaveBeenCalledWith(
mockCollaboratorAdmin1.email,
"Isomer Team",
[mockCollaboratorAdmin2.email, mockEmailAddress, ISOMER_SUPPORT_EMAIL],
ISOMER_SUPPORT_EMAIL,
"subject",
"body"
)
})

it("should return a NotFoundError if the site does not have any admins", async () => {
// Arrange
mockSiteRepo.findOne.mockResolvedValue(
mockSiteOrmResponseWithOneContributorCollaborator
)
const spySendMailWithCc = jest.spyOn(mailer, "sendMailWithCc")

// Act
const result = await collaboratorsService.notify(
mockSiteName,
"subject",
"body",
mockEmailAddress
)

// Assert
expect(result).toBeInstanceOf(NotFoundError)
expect(mockSiteRepo.findOne).toHaveBeenCalledOnce()
expect(spySendMailWithCc).not.toHaveBeenCalled()
})
})

describe("notifyWithDnsRecords", () => {
it("should send an email to all site admins with DNS records", async () => {
// Arrange
mockSiteRepo.findOne.mockResolvedValue(
mockSiteOrmResponseWithAllCollaborators
)
const spySendMailWithCc = jest.spyOn(mailer, "sendMailWithCc")
spySendMailWithCc.mockResolvedValueOnce()
const dnsRecords = [
{
source: "source",
target: "target",
type: "A" as const,
},
]

// Act
await collaboratorsService.notifyWithDnsRecords(
"main",
mockSiteName,
"domain",
dnsRecords,
mockEmailAddress
)

// Assert
expect(mockSiteRepo.findOne).toHaveBeenCalledOnce()
expect(spySendMailWithCc).toHaveBeenCalledWith(
mockCollaboratorAdmin1.email,
"Isomer Team",
[mockCollaboratorAdmin2.email, mockEmailAddress, ISOMER_SUPPORT_EMAIL],
ISOMER_SUPPORT_EMAIL,
expect.any(String),
expect.any(String)
)
})
})
})
28 changes: 26 additions & 2 deletions src/services/utilServices/MailClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,14 +43,38 @@ class MailClient {
subject: string,
body: string,
attachments?: string[]
): Promise<void> {
await this.sendMailWithCc(
recipient,
"IsomerCMS",
[],
"[email protected]",
subject,
body,
attachments
)
}

async sendMailWithCc(
recipient: string,
senderName: string,
cc: string[],
replyTo: string,
subject: string,
body: string,
attachments?: string[]
): Promise<void> {
const sendEndpoint = `${POSTMAN_API_URL}/transactional/email/send`
const form = new FormData()
form.append("subject", subject)
form.append("from", "IsomerCMS <[email protected]>")
form.append("from", `${senderName} <[email protected]>`)
form.append("body", body)
form.append("recipient", recipient)
form.append("reply_to", "[email protected]")
form.append("reply_to", replyTo)
cc.forEach((ccEmail) => {
form.append("cc", ccEmail)
})

if (attachments) {
await Promise.all(
attachments?.map(async (attachment) => {
Expand Down
Loading

0 comments on commit b0428e0

Please sign in to comment.