diff --git a/packages/backend/src/Controllers/Election/sendEmailController.ts b/packages/backend/src/Controllers/Election/sendEmailController.ts index ffb98d71f..119ad076d 100644 --- a/packages/backend/src/Controllers/Election/sendEmailController.ts +++ b/packages/backend/src/Controllers/Election/sendEmailController.ts @@ -12,6 +12,7 @@ import { IElectionRequest } from "../../IRequest"; import { Response, NextFunction } from 'express'; import { Imsg } from '../../Services/Email/IEmail'; import { logSafeHash } from '../../Services/Logging/logSafeHash'; +import { recordInviteResponse } from './sendInvitesController'; var ElectionRollModel = ServiceLocator.electionRollDb(); var ElectionModel = ServiceLocator.electionsDb(); @@ -32,6 +33,10 @@ export type email_request_data = { } target: 'all' | 'has_voted' | 'has_not_voted' | 'single' | 'test', testEmails?: string[], + // Which template the blast started from. 'invite' blasts update each targeted roll's + // email_data.inviteResponse (the "Email invite status" the admin UI shows); other + // blasts (e.g. 'blank' updates/reminders) leave the invite status untouched. + template?: 'invite' | 'blank', } export type email_request_event = { @@ -46,7 +51,8 @@ export type email_request_event = { }, message_id: string, sender: string, - test_email: string // empty string implies it's a real email + test_email: string, // empty string implies it's a real email + template?: 'invite' | 'blank', } const makeTestRoll = (election_id: string, email: string) => { @@ -151,7 +157,8 @@ const sendEmailsController = async (req: IElectionRequest, res: Response, next: sender: req.user.email, email: email_request.email, message_id: message_id, - test_email: email_request.target == 'test' ? (roll.email ?? '') : '' + test_email: email_request.target == 'test' ? (roll.email ?? '') : '', + template: email_request.template } ) }) @@ -223,6 +230,14 @@ async function handleSendEmailEvent(job: { id: string; data: email_request_event if(event.test_email) return; // skip the database updates if it's a test email + if (event.template === 'invite') { + // Invitation blasts are how invites are actually sent from the current UI, but only + // the legacy sendInvite(s) endpoints used to write email_data.inviteResponse — the + // field the roll table's "Email Invites" column reads — so every row kept reading + // "Not Sent" after a blast. Record it here the same way the legacy path does. + recordInviteResponse(electionRoll, emailResponse) + } + const historyUpdate: ElectionRollAction = { action_type: event.message_id, actor: event.sender, diff --git a/packages/backend/src/Controllers/Election/sendInvitesController.ts b/packages/backend/src/Controllers/Election/sendInvitesController.ts index 88949b9f6..9b5c1bedc 100644 --- a/packages/backend/src/Controllers/Election/sendInvitesController.ts +++ b/packages/backend/src/Controllers/Election/sendInvitesController.ts @@ -131,22 +131,28 @@ async function handleSendInviteEvent(job: { id: string; data: SendInviteEvent; } await sendInvitation(ctx, event.election, electionRoll, event.sender, event.url) } -async function sendInvitation(ctx: any, election:Election, electionRoll: ElectionRoll, sender: string, url: string) { - const invites = Invites(election, [electionRoll], url) - const emailResponse = await EmailService.sendEmails(invites) +// Records an email-send response onto the roll's email_data.inviteResponse — the field the +// admin UI reads to render the per-voter "Email invite status" (EnhancedTable's invite_status +// column and EditElectionRoll). Shared by the legacy sendInvite(s) endpoints and the +// email-blast path (sendEmailController) so both report status the same way. +// Returns whether the send succeeded (statusCode < 400). +export function recordInviteResponse(electionRoll: ElectionRoll, emailResponse: any): boolean { if (!electionRoll.email_data) { electionRoll.email_data = {} } //Should have an array of one response in which case grab the first, but could have an error message - let emailSuccess = false - if (emailResponse.length > 0) { + if (emailResponse && emailResponse.length > 0) { electionRoll.email_data.inviteResponse = emailResponse[0] - if (emailResponse[0][0].statusCode < 400) { - emailSuccess = true - } - } else { - electionRoll.email_data.inviteResponse = emailResponse + return emailResponse[0][0].statusCode < 400 } + electionRoll.email_data.inviteResponse = emailResponse + return false +} + +async function sendInvitation(ctx: any, election:Election, electionRoll: ElectionRoll, sender: string, url: string) { + const invites = Invites(election, [electionRoll], url) + const emailResponse = await EmailService.sendEmails(invites) + const emailSuccess = recordInviteResponse(electionRoll, emailResponse) // Record the sent event in the email events table const xMessageId = emailResponse?.[0]?.[0]?.headers?.['x-message-id']; if (xMessageId) { diff --git a/packages/backend/src/Services/Email/__mocks__/EmailService.ts b/packages/backend/src/Services/Email/__mocks__/EmailService.ts index 07c4835dd..f1667fb0e 100644 --- a/packages/backend/src/Services/Email/__mocks__/EmailService.ts +++ b/packages/backend/src/Services/Email/__mocks__/EmailService.ts @@ -10,6 +10,10 @@ export default class EmailService { sendEmails = async (msg: Imsg[]) => { this.sentEmails.push(...msg) + // Mirror the shape the real EmailService returns from @sendgrid/mail: + // one [ClientResponse, body] pair per message (see how sendInvitesController + // reads emailResponse[0][0].statusCode). + return msg.map(() => [{ statusCode: 202, headers: {} }, {}]) } clear = () => { diff --git a/packages/backend/src/test/sendEmailInviteStatus.test.ts b/packages/backend/src/test/sendEmailInviteStatus.test.ts new file mode 100644 index 000000000..b00f8d375 --- /dev/null +++ b/packages/backend/src/test/sendEmailInviteStatus.test.ts @@ -0,0 +1,116 @@ +require("dotenv").config(); +const request = require("supertest"); +import makeApp from "../app"; +import { MockEventQueue } from "../Services/EventQueue/MockEventQueue"; +import { TestHelper } from "./TestHelper"; +import testInputs from "./testInputs"; + +const app = makeApp(); +const th = new TestHelper(); + +// The mock event queue processes email jobs asynchronously with a 1s delay per job. +jest.setTimeout(30000); +const waitForQueue = async () => (await th.eventQueue).waitUntilJobsFinished(); + +const fetchRolls = async (electionId: string) => { + const response = await th.fetchElectionRoll(electionId, testInputs.user1token); + expect(response.statusCode).toBe(200); + return response.body.electionRoll; +}; + +// waitUntilJobsFinished can return while the final shifted job is still being handled, +// so poll briefly for the expected roll state instead of asserting immediately. +const fetchRollsUntil = async (electionId: string, predicate: (rolls: any[]) => boolean) => { + let rolls = await fetchRolls(electionId); + for (let i = 0; i < 10 && !predicate(rolls); i++) { + await new Promise((r) => setTimeout(r, 500)); + rolls = await fetchRolls(electionId); + } + return rolls; +}; + +afterEach(() => { + jest.clearAllMocks(); + th.afterEach(); +}); + +// The roll's "Email invite status" (email_data.inviteResponse) used to be written only by +// the legacy sendInvites/sendInvite endpoints, which no current UI calls. Invitations +// actually go out through the email-blast endpoint (sendEmails), which didn't write it — +// so after an admin sent invites, every roll row still read "Invite not sent". +// These tests pin the fix: an 'invite'-template blast records the invite status on each +// targeted roll; other blasts leave it untouched. +describe("Email blast invite status", () => { + beforeAll(() => { + jest.clearAllMocks(); + }); + var electionId = ""; + + test("Create email-list election and add roll", async () => { + const electionResponse = await th.createElection( + testInputs.EmailRollElection, + testInputs.user1token + ); + expect(electionResponse.statusCode).toBe(200); + electionId = electionResponse.election.election_id; + + const rollResponse = await th.submitElectionRoll( + electionId, + testInputs.EmailRoll, + testInputs.user1token + ); + expect(rollResponse.statusCode).toBe(200); + th.testComplete(); + }); + + test("A non-invite blast does not mark voters as invited", async () => { + const response = await request(app) + .post(`/API/Election/${electionId}/sendEmails`) + .set("Cookie", ["id_token=" + testInputs.user1token]) + .set("Accept", "application/json") + .send({ + target: "all", + email: { subject: "An update", body: "Just an update, not an invitation" }, + template: "blank", + }); + expect(response.statusCode).toBe(200); + await waitForQueue(); + + // The blast is recorded in history (via the queue), but must NOT flip the + // "Email invite status" — that would report an invite that wasn't sent. + const rolls = await fetchRollsUntil(electionId, (rolls) => + rolls.every((roll: any) => (roll.history?.length ?? 0) > 0) + ); + expect(rolls.length).toBe(testInputs.EmailRoll.length); + rolls.forEach((roll: any) => { + expect(roll.email_data?.inviteResponse).toBeUndefined(); + }); + th.testComplete(); + }); + + test("An invite-template blast marks each targeted voter as invited", async () => { + const response = await request(app) + .post(`/API/Election/${electionId}/sendEmails`) + .set("Cookie", ["id_token=" + testInputs.user1token]) + .set("Accept", "application/json") + .send({ + target: "all", + email: { subject: "Invitation to vote", body: "You are invited __VOTE_BUTTON__" }, + template: "invite", + }); + expect(response.statusCode).toBe(200); + await waitForQueue(); + + const rolls = await fetchRollsUntil(electionId, (rolls) => + rolls.every((roll: any) => roll.email_data?.inviteResponse !== undefined) + ); + expect(rolls.length).toBe(testInputs.EmailRoll.length); + rolls.forEach((roll: any) => { + // Same shape the legacy sendInvites path writes, and what the admin UI reads: + // inviteResponse[0].statusCode < 400 renders as "Sent". + expect(roll.email_data?.inviteResponse).toBeDefined(); + expect(roll.email_data.inviteResponse[0].statusCode).toBeLessThan(400); + }); + th.testComplete(); + }); +}); diff --git a/packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx b/packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx index ad3c817be..700491141 100644 --- a/packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx +++ b/packages/frontend/src/components/Election/Admin/EditElectionRoll.tsx @@ -57,11 +57,13 @@ const EditElectionRoll = ({ roll, fetchRolls }:Props) => { subject, body, target, + template, } : { subject: string, body: string, target: 'all' | 'has_voted' | 'has_not_voted' | 'single' | 'test' testEmails: string[], + template?: 'invite' | 'blank', }) => { setDialogOpen(false); @@ -70,9 +72,11 @@ const EditElectionRoll = ({ roll, fetchRolls }:Props) => { email: { subject: string, body: string }, voter_id?: string, recipient_email?: string, + template?: 'invite' | 'blank', } = { target, email: { subject, body }, + template, }; if (roll.voter_id) { diff --git a/packages/frontend/src/components/Election/Admin/SendEmailDialog.tsx b/packages/frontend/src/components/Election/Admin/SendEmailDialog.tsx index 04384385d..be1525785 100644 --- a/packages/frontend/src/components/Election/Admin/SendEmailDialog.tsx +++ b/packages/frontend/src/components/Election/Admin/SendEmailDialog.tsx @@ -11,7 +11,7 @@ import { ElectionRoll } from "@equal-vote/star-vote-shared/domain_model/Election interface SendEmailDialogProps { open: boolean; onClose: () => void; - onSubmit: (data: { subject: string, body: string, target: string }) => void; + onSubmit: (data: { subject: string, body: string, target: string, template?: 'invite' | 'blank' }) => void; targetedEmail?: string; electionRoll?: ElectionRoll[]; // TODO: replace this with the official type } @@ -28,6 +28,7 @@ const SendEmailDialog = ({open, onClose, onSubmit, targetedEmail=undefined, elec const authSession = useAuthSession() const [audience, setAudience] = useState(targetedEmail? 'single' : 'all') const [templateChosen, setTemplateChosen] = useState(false) + const [templateId, setTemplateId] = useState<'invite' | 'blank' | undefined>(undefined) const [emailSubject, setEmailSubject] = useState('Update for Election') const [emailBody, setEmailBody] = useState('') const [testEmails, setTestEmails] = useState(authSession.getIdField('email')) // TODO: replace this with the official type @@ -59,6 +60,7 @@ const SendEmailDialog = ({open, onClose, onSubmit, targetedEmail=undefined, elec const setTemplate = (template_id) => { setTemplateChosen(true); + setTemplateId(template_id); setEmailSubject(t(`emails.${template_id}.subject`,{ skipProcessing: true, // processing must occur in backend title: election.title, @@ -181,6 +183,7 @@ const SendEmailDialog = ({open, onClose, onSubmit, targetedEmail=undefined, elec subject: emailSubject, body: emailBody, target: audience, + template: templateId, }) }}> {targetedEmail? 'Send Email' : `Send ${getVoterCount()} Emails`} diff --git a/packages/frontend/src/components/Election/Admin/ViewElectionRolls.tsx b/packages/frontend/src/components/Election/Admin/ViewElectionRolls.tsx index dd90e0872..65c6b6cb1 100644 --- a/packages/frontend/src/components/Election/Admin/ViewElectionRolls.tsx +++ b/packages/frontend/src/components/Election/Admin/ViewElectionRolls.tsx @@ -56,15 +56,18 @@ const ViewElectionRolls = () => { subject, body, target, + template, } : { subject: string, body: string, - target: 'all' | 'has_voted' | 'has_not_voted' | 'single' + target: 'all' | 'has_voted' | 'has_not_voted' | 'single', + template?: 'invite' | 'blank' }) => { setDialogOpen(false); sendEmails.makeRequest({ target: target, email: { subject, body }, + template: template, }) }