Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions packages/backend/src/Controllers/Election/sendEmailController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 = {
Expand All @@ -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) => <ElectionRoll>{
Expand Down Expand Up @@ -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
}
)
})
Expand Down Expand Up @@ -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,
Expand Down
26 changes: 16 additions & 10 deletions packages/backend/src/Controllers/Election/sendInvitesController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions packages/backend/src/Services/Email/__mocks__/EmailService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {
Expand Down
116 changes: 116 additions & 0 deletions packages/backend/src/test/sendEmailInviteStatus.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
}

Expand Down
Loading