diff --git a/actions/services/notifyDirectService.js b/actions/services/notifyDirectService.js
index b36b64de..491b4d63 100644
--- a/actions/services/notifyDirectService.js
+++ b/actions/services/notifyDirectService.js
@@ -25,3 +25,24 @@ export const sendEmail = async (
throw error;
}
};
+
+export const sendContactUsConfirmation = async (formValues, language) => {
+ const mailData = {
+ "contactEmail": formValues.contactEmail,
+ "contactSubject": formValues.contactSubject,
+ "contactbody": formValues.contactbody,
+ "typeOfContact": formValues.typeOfContact,
+ "language": language
+ };
+
+ try {
+ return await requestJson({
+ method: "post",
+ url: "/api/email/contactus-confirmation",
+ data: mailData
+ });
+ } catch (error) {
+ consoleLogger(error);
+ throw error;
+ }
+};
diff --git a/lib/security/contactus-email-links.js b/lib/security/contactus-email-links.js
new file mode 100644
index 00000000..cc49475a
--- /dev/null
+++ b/lib/security/contactus-email-links.js
@@ -0,0 +1,81 @@
+const LINK_REMOVED = "[web link removed]";
+const OBFUSCATING_SPACE = "[\\s\\u0000-\\u001f\\u007f\\u200b-\\u200d\\ufeff]*";
+const SCHEME = `h${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}t${OBFUSCATING_SPACE}p(?:${OBFUSCATING_SPACE}s)?${OBFUSCATING_SPACE}:${OBFUSCATING_SPACE}\\/${OBFUSCATING_SPACE}\\/`;
+const DOMAIN = "(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}";
+const URL_TAIL = "[^\\s<>\\\"']*";
+
+const explicitUrlPattern = () => new RegExp(`${SCHEME}${DOMAIN}${URL_TAIL}`, "gi");
+const wwwUrlPattern = () => new RegExp(`\\bwww\\.${DOMAIN}${URL_TAIL}`, "gi");
+const bareDomainWithPathPattern = () =>
+ new RegExp(`(^|[^a-z0-9@._-])(${DOMAIN}\\/[^\\s<>\\\"']*)`, "gi");
+
+const stripTrailingPunctuation = (value) => {
+ let destination = value;
+ let punctuation = "";
+
+ while (/[.,!;:]$/.test(destination)) {
+ punctuation = destination.slice(-1) + punctuation;
+ destination = destination.slice(0, -1);
+ }
+
+ while (/[)\]}]$/.test(destination)) {
+ punctuation = destination.slice(-1) + punctuation;
+ destination = destination.slice(0, -1);
+ }
+
+ return { destination, punctuation };
+};
+
+const replaceUrlMatch = (value) => {
+ const { destination, punctuation } = stripTrailingPunctuation(value);
+ return destination ? LINK_REMOVED + punctuation : value;
+};
+
+const containsWebDestination = (value) => {
+ explicitUrlPattern().lastIndex = 0;
+ wwwUrlPattern().lastIndex = 0;
+ bareDomainWithPathPattern().lastIndex = 0;
+
+ return (
+ explicitUrlPattern().test(value) ||
+ wwwUrlPattern().test(value) ||
+ bareDomainWithPathPattern().test(value)
+ );
+};
+
+export default function removeContactUsEmailLinks(message) {
+ if (typeof message !== "string") {
+ return message;
+ }
+
+ let transformed = message;
+
+ transformed = transformed.replace(
+ /]*>([\s\S]*?)<\/a\s*>/gi,
+ (match, linkText) =>
+ containsWebDestination(match)
+ ? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
+ : match
+ );
+
+ transformed = transformed.replace(/<[^>]+>/gi, (tag) =>
+ containsWebDestination(tag) ? LINK_REMOVED : tag
+ );
+
+ transformed = transformed.replace(
+ /\[([^\]]*)\]\(([^)]*)\)/gi,
+ (match, linkText, destination) =>
+ containsWebDestination(destination)
+ ? `${linkText}${linkText ? " " : ""}${LINK_REMOVED}`
+ : match
+ );
+
+ transformed = transformed.replace(explicitUrlPattern(), replaceUrlMatch);
+ transformed = transformed.replace(wwwUrlPattern(), replaceUrlMatch);
+ transformed = transformed.replace(
+ bareDomainWithPathPattern(),
+ (match, prefix, destination) => prefix + replaceUrlMatch(destination)
+ );
+
+ return transformed;
+}
diff --git a/pages/api/email/contactus-confirmation.js b/pages/api/email/contactus-confirmation.js
new file mode 100644
index 00000000..3ebe8393
--- /dev/null
+++ b/pages/api/email/contactus-confirmation.js
@@ -0,0 +1,246 @@
+import { isNonEmptyString, sanitizeString } from "../../../actions/core/guards";
+import { consoleLogger } from "../../../actions/core/logger";
+import removeContactUsEmailLinks from "../../../lib/security/contactus-email-links";
+import { respondError, respondSuccess } from "../middleware/apiResponse";
+
+const TEMPLATE_IDS = {
+ en: "69228c5f-8500-40ff-a6c5-95a9434c3ec8",
+ cy: "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3"
+};
+
+const REFERENCE = "PEDW-CONTACT-US";
+
+const allowedSubjects = {
+ plq: [
+ "Questions about Planning legislation or policy",
+ "Planning legislation query",
+ "Ymholiad deddfwriaeth gynllunio"
+ ],
+ ipq: [
+ "Questions or comments about applications for major infrastructure projects",
+ "Infrastructure procedure query",
+ "Ymholiad gweithdrefn seilwaith"
+ ],
+ pcpq: [
+ "Questions or comments about planning and environmental appeals",
+ "Planning casework procedure query",
+ "Ymholiad gweithdrefn gwaith achos cynllunio"
+ ],
+ pi: ["Query about using the Casework Portal", "Portal Issues", "Materion Porth"],
+ appadvice: [
+ "Advice How to submit an appeal",
+ "Appeal submission advice",
+ "Cyngor cyflwyno apêl"
+ ],
+ repadvice: [
+ "Advice How to comment on an appeal or application",
+ "Representation submission advice",
+ "Cyngor cyflwyno sylwadau"
+ ],
+ ceq: [
+ "Queries about forthcoming Hearing or Inquiry event",
+ "Casework Events Query",
+ "Ymholiad Digwyddiadau Gwaith Achos"
+ ],
+ other: ["Other enquiry for PEDW", "Any Other Enquiries", "Unrhyw Ymholiadau Eraill"],
+ postdec: [
+ "Issues or questions about a case after the decision is issued",
+ "Post Decision",
+ "Ar ôl Penderfyniad"
+ ],
+ comp: [
+ "General Complaints of Compliments",
+ "Complaints/Complements",
+ "Cwynion/Canmoliaethau"
+ ]
+};
+
+const allowedFields = new Set([
+ "contactEmail",
+ "contactSubject",
+ "contactbody",
+ "typeOfContact",
+ "language"
+]);
+
+const emailRegex =
+ /^(?!.*(?:.+\.)?(?:burpcollaborator\.net|interactsh\.com|mailinator\.com|10minutemail\.com|yopmail\.com|trashmail\.com)$)(?:[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-zA-Z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}|(?:\[(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-zA-Z0-9-]*[a-zA-Z0-9]:.+)])$/;
+
+// Origin checking is browser defence in depth only. It is not authentication and
+// does not prevent a direct HTTP client from attempting abuse.
+const hasTrustedOrigin = (req) => {
+ const requestOrigin = req?.headers?.origin;
+
+ if (typeof requestOrigin !== "string" || requestOrigin === "null") {
+ return false;
+ }
+
+ try {
+ const requestUrl = new URL(requestOrigin);
+ if (requestOrigin !== requestUrl.origin) {
+ return false;
+ }
+
+ const trustedOrigins = [process.env.NEXTAUTH_URL, process.env.CY_API_ROOT]
+ .filter(
+ (configuredUrl) =>
+ typeof configuredUrl === "string" && configuredUrl.length > 0
+ )
+ .map((configuredUrl) => new URL(configuredUrl).origin);
+
+ return trustedOrigins.includes(requestUrl.origin);
+ } catch (_error) {
+ return false;
+ }
+};
+
+const validationError = (res, code, message) =>
+ respondError(res, { status: 400, code, message });
+
+const isPlainObject = (value) => {
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
+ return false;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === null || prototype?.constructor?.name === "Object";
+};
+
+export default async function ApiProxy(req, res) {
+ if (req.method !== "POST") {
+ res.setHeader("Allow", "POST");
+ return respondError(res, {
+ status: 405,
+ code: "METHOD_NOT_ALLOWED",
+ message: "Method not allowed"
+ });
+ }
+
+ const contentType = req.headers?.["content-type"];
+ if (
+ typeof contentType !== "string" ||
+ contentType.split(";", 1)[0].trim().toLowerCase() !== "application/json"
+ ) {
+ return respondError(res, {
+ status: 415,
+ code: "UNSUPPORTED_MEDIA_TYPE",
+ message: "Content-Type must be application/json"
+ });
+ }
+
+ if (!hasTrustedOrigin(req)) {
+ return respondError(res, {
+ status: 403,
+ code: "FORBIDDEN",
+ message: "Request not permitted"
+ });
+ }
+
+ const requestBody = req.body;
+ if (!isPlainObject(requestBody)) {
+ return validationError(
+ res,
+ "INVALID_REQUEST_BODY",
+ "Request body must be a JSON object"
+ );
+ }
+
+ const requestFields = Object.keys(requestBody);
+ if (
+ requestFields.length !== allowedFields.size ||
+ requestFields.some((field) => !allowedFields.has(field))
+ ) {
+ return validationError(
+ res,
+ "INVALID_REQUEST_FIELDS",
+ "Request must contain exactly the supported fields"
+ );
+ }
+
+ const contactEmail = sanitizeString(requestBody.contactEmail);
+ const contactSubject = sanitizeString(requestBody.contactSubject);
+ const contactBody = sanitizeString(requestBody.contactbody);
+ const typeOfContact = requestBody.typeOfContact;
+ const language = requestBody.language;
+
+ if (
+ typeof requestBody.contactEmail !== "string" ||
+ !isNonEmptyString(contactEmail) ||
+ contactEmail.length > 200 ||
+ !emailRegex.test(contactEmail)
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_EMAIL",
+ "contactEmail must be a valid email address of 200 characters or fewer"
+ );
+ }
+
+ if (
+ typeof typeOfContact !== "string" ||
+ !Object.prototype.hasOwnProperty.call(allowedSubjects, typeOfContact)
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_TYPE",
+ "typeOfContact is not supported"
+ );
+ }
+
+ if (
+ typeof requestBody.contactbody !== "string" ||
+ !isNonEmptyString(contactBody) ||
+ contactBody.length > 800
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_BODY",
+ "contactbody must contain between 1 and 800 characters"
+ );
+ }
+
+ if (
+ typeof requestBody.contactSubject !== "string" ||
+ !allowedSubjects[typeOfContact].includes(contactSubject)
+ ) {
+ return validationError(
+ res,
+ "INVALID_CONTACT_SUBJECT",
+ "contactSubject does not match typeOfContact"
+ );
+ }
+
+ if (language !== "en" && language !== "cy") {
+ return validationError(
+ res,
+ "INVALID_LANGUAGE",
+ "language must be either en or cy"
+ );
+ }
+
+ const personalisation = {
+ contactEmail,
+ contactSubject,
+ contactbody: removeContactUsEmailLinks(contactBody),
+ typeOfContact
+ };
+
+ const NotifyClient = require("notifications-node-client").NotifyClient;
+ const notifyClient = new NotifyClient(process.env.NOTIFY_API_KEY);
+
+ try {
+ await notifyClient.sendEmail(TEMPLATE_IDS[language], contactEmail, {
+ personalisation,
+ reference: REFERENCE
+ });
+
+ return respondSuccess(res, { sent: true });
+ } catch (error) {
+ consoleLogger(error);
+ return respondError(res, {
+ status: 400,
+ code: "CONTACT_US_CONFIRMATION_FAILED",
+ message: "Failed to send Contact Us confirmation email"
+ });
+ }
+}
diff --git a/pages/contactus.js b/pages/contactus.js
index e877ceb9..9266efb3 100644
--- a/pages/contactus.js
+++ b/pages/contactus.js
@@ -18,6 +18,7 @@ import {
import { connect } from "react-redux";
import { createCRMTask } from "../actions/services/integrationService";
import { sendEmail } from "../actions/services/notifyService";
+import { sendContactUsConfirmation } from "../actions/services/notifyDirectService";
import { wrapper } from "../store/store";
import { useState } from "react";
@@ -329,16 +330,10 @@ let ContactUs = (props) => {
.children
});
- const reference = "PEDW-CONTACT-US";
- const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
- const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
-
try {
- const res = await sendEmail(
- router.locale !== "en" ? templateIdCY : templateId,
- formValues.contactEmail,
+ const res = await sendContactUsConfirmation(
formValues,
- reference
+ router.locale !== "en" ? "cy" : "en"
);
console.log("Email sent successfully.");
diff --git a/pages/myportal/contactus.js b/pages/myportal/contactus.js
index 05ea94b7..fd4251e8 100644
--- a/pages/myportal/contactus.js
+++ b/pages/myportal/contactus.js
@@ -18,6 +18,7 @@ import {
import { connect } from "react-redux";
import { createCRMTask } from "../../actions/services/integrationService";
import { sendEmail } from "../../actions/services/notifyService";
+import { sendContactUsConfirmation } from "../../actions/services/notifyDirectService";
import { wrapper } from "../../store/store";
import { useState } from "react";
@@ -329,16 +330,10 @@ let ContactUs = (props) => {
.children
});
- const reference = "PEDW-CONTACT-US";
- const templateId = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
- const templateIdCY = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
-
try {
- const res = await sendEmail(
- router.locale !== "en" ? templateIdCY : templateId,
- formValues.contactEmail,
+ const res = await sendContactUsConfirmation(
formValues,
- reference
+ router.locale !== "en" ? "cy" : "en"
);
console.log("Email sent successfully.");
diff --git a/tests/phase21/email-handler-contract.test.cjs b/tests/phase21/email-handler-contract.test.cjs
index c648325c..a861a135 100644
--- a/tests/phase21/email-handler-contract.test.cjs
+++ b/tests/phase21/email-handler-contract.test.cjs
@@ -337,6 +337,386 @@ test("notify send failure returns EMAIL_NOTIFY_FAILED", async () => {
assert.strictEqual(res.state.jsonBody.error.code, "EMAIL_NOTIFY_FAILED");
});
+const trustedContactOrigin = "https://portal.example.test";
+const trustedWelshContactOrigin = "https://porth.example.test";
+const contactConfirmationTemplateEn = "69228c5f-8500-40ff-a6c5-95a9434c3ec8";
+const contactConfirmationTemplateCy = "b8f1f76d-4ab9-4a2f-b294-e3623c6095a3";
+
+const contactSubjects = {
+ plq: {
+ en: "Questions about Planning legislation or policy",
+ cy: "Ymholiad deddfwriaeth gynllunio"
+ },
+ ipq: {
+ en: "Questions or comments about applications for major infrastructure projects",
+ cy: "Ymholiad gweithdrefn seilwaith"
+ },
+ pcpq: {
+ en: "Questions or comments about planning and environmental appeals",
+ cy: "Ymholiad gweithdrefn gwaith achos cynllunio"
+ },
+ pi: { en: "Query about using the Casework Portal", cy: "Materion Porth" },
+ appadvice: { en: "Advice How to submit an appeal", cy: "Cyngor cyflwyno apêl" },
+ repadvice: {
+ en: "Advice How to comment on an appeal or application",
+ cy: "Cyngor cyflwyno sylwadau"
+ },
+ ceq: {
+ en: "Queries about forthcoming Hearing or Inquiry event",
+ cy: "Ymholiad Digwyddiadau Gwaith Achos"
+ },
+ other: { en: "Other enquiry for PEDW", cy: "Unrhyw Ymholiadau Eraill" },
+ postdec: {
+ en: "Issues or questions about a case after the decision is issued",
+ cy: "Ar ôl Penderfyniad"
+ },
+ comp: {
+ en: "General Complaints of Compliments",
+ cy: "Cwynion/Canmoliaethau"
+ }
+};
+
+const loadContactLinkUtility = () =>
+ loadModule("lib/security/contactus-email-links.js").default;
+
+const validContactConfirmationRequest = (overrides = {}) => ({
+ method: "POST",
+ headers: {
+ origin: trustedContactOrigin,
+ "content-type": "application/json; charset=utf-8"
+ },
+ body: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.ipq.en,
+ contactbody: "Please help with my enquiry.",
+ typeOfContact: "ipq",
+ language: "en"
+ },
+ ...overrides
+});
+
+const loadContactConfirmationEndpoint = ({ sendEmail } = {}) => {
+ const notifyCalls = [];
+ const removeContactUsEmailLinks = loadContactLinkUtility();
+ const notifySend =
+ sendEmail ||
+ (async (...args) => {
+ notifyCalls.push(args);
+ return { id: "notify-1" };
+ });
+
+ const mod = loadModule("pages/api/email/contactus-confirmation.js", {
+ respondError: respondErrorMock,
+ respondSuccess: respondSuccessMock,
+ sanitizeString: (value) => (typeof value === "string" ? value.trim() : ""),
+ isNonEmptyString: (value) =>
+ typeof value === "string" && value.trim().length > 0,
+ removeContactUsEmailLinks,
+ consoleLogger: () => {},
+ require: (name) => {
+ if (name === "notifications-node-client") {
+ return {
+ NotifyClient: function NotifyClient() {
+ return { sendEmail: notifySend };
+ }
+ };
+ }
+
+ return require(name);
+ }
+ });
+
+ return { mod, notifyCalls };
+};
+
+const withContactOrigins = async (fn) => {
+ const previousNextAuthUrl = process.env.NEXTAUTH_URL;
+ const previousCyApiRoot = process.env.CY_API_ROOT;
+ process.env.NEXTAUTH_URL = trustedContactOrigin;
+ process.env.CY_API_ROOT = trustedWelshContactOrigin;
+
+ try {
+ await fn();
+ } finally {
+ if (previousNextAuthUrl === undefined) delete process.env.NEXTAUTH_URL;
+ else process.env.NEXTAUTH_URL = previousNextAuthUrl;
+
+ if (previousCyApiRoot === undefined) delete process.env.CY_API_ROOT;
+ else process.env.CY_API_ROOT = previousCyApiRoot;
+ }
+};
+
+const assertContactConfirmationRejected = async ({ req, status, code }) => {
+ await withContactOrigins(async () => {
+ const loaded = loadContactConfirmationEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, status);
+ assert.strictEqual(res.state.jsonBody.error.code, code);
+ assert.strictEqual(loaded.notifyCalls.length, 0);
+ });
+};
+
+test("Contact Us link removal preserves text while removing recognised destinations", async () => {
+ const removeLinks = loadContactLinkUtility();
+ const input = [
+ "Please review https://example.invalid/path?token=abc#details.",
+ "Also HTTP://OTHER.INVALID/login and www.third.invalid/help.",
+ "Bare planning.example.invalid/application/123 remains represented safely.",
+ "Email applicant@example.com and reference APP/123456 must remain.",
+ "Markdown [review this](https://markdown.invalid/login).",
+ 'HTML review this.',
+ 'Tag
remains non-actionable.',
+ "Obfuscated h\u200btt\u200bps://obfuscated.invalid/login is removed."
+ ].join("\n");
+
+ const output = removeLinks(input);
+
+ assert.strictEqual(
+ output,
+ [
+ "Please review [web link removed].",
+ "Also [web link removed] and [web link removed].",
+ "Bare [web link removed] remains represented safely.",
+ "Email applicant@example.com and reference APP/123456 must remain.",
+ "Markdown review this [web link removed].",
+ "HTML review this [web link removed].",
+ "Tag [web link removed] remains non-actionable.",
+ "Obfuscated [web link removed] is removed."
+ ].join("\n")
+ );
+ assert.strictEqual(input.includes("https://example.invalid"), true);
+});
+
+test("Contact Us confirmation is POST-only with Allow header", async () => {
+ const req = validContactConfirmationRequest({ method: "GET" });
+ await withContactOrigins(async () => {
+ const loaded = loadContactConfirmationEndpoint();
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 405);
+ assert.strictEqual(res.state.jsonBody.error.code, "METHOD_NOT_ALLOWED");
+ assert.strictEqual(res.state.headers.allow, "POST");
+ assert.strictEqual(loaded.notifyCalls.length, 0);
+ });
+});
+
+for (const contentType of [undefined, "text/plain", "application/x-www-form-urlencoded"]) {
+ test(`Contact Us confirmation rejects content type ${String(contentType)}`, async () => {
+ const req = validContactConfirmationRequest({
+ headers: {
+ origin: trustedContactOrigin,
+ ...(contentType ? { "content-type": contentType } : {})
+ }
+ });
+ await assertContactConfirmationRejected({
+ req,
+ status: 415,
+ code: "UNSUPPORTED_MEDIA_TYPE"
+ });
+ });
+}
+
+for (const origin of [undefined, "null", "https://attacker.example", `${trustedContactOrigin}/path`]) {
+ test(`Contact Us confirmation rejects origin ${String(origin)}`, async () => {
+ const req = validContactConfirmationRequest({
+ headers: {
+ ...(origin ? { origin } : {}),
+ "content-type": "application/json"
+ }
+ });
+ await assertContactConfirmationRejected({ req, status: 403, code: "FORBIDDEN" });
+ });
+}
+
+test("Contact Us confirmation accepts each configured exact origin", async () => {
+ await withContactOrigins(async () => {
+ for (const origin of [trustedContactOrigin, trustedWelshContactOrigin]) {
+ const loaded = loadContactConfirmationEndpoint();
+ const req = validContactConfirmationRequest({
+ headers: { origin, "content-type": "application/json" }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.notifyCalls.length, 1);
+ }
+ });
+});
+
+for (const body of [null, [], "not-an-object", new Date()]) {
+ test(`Contact Us confirmation rejects non-plain body ${Object.prototype.toString.call(body)}`, async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({ body }),
+ status: 400,
+ code: "INVALID_REQUEST_BODY"
+ });
+ });
+}
+
+for (const bodyOverride of [
+ { extra: "field" },
+ { language: undefined },
+ { contactSubject: undefined }
+]) {
+ test(`Contact Us confirmation enforces exact fields ${Object.keys(bodyOverride).join(",")}`, async () => {
+ const body = { ...validContactConfirmationRequest().body, ...bodyOverride };
+ if (Object.values(bodyOverride).includes(undefined)) {
+ for (const key of Object.keys(bodyOverride)) delete body[key];
+ }
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({ body }),
+ status: 400,
+ code: "INVALID_REQUEST_FIELDS"
+ });
+ });
+}
+
+for (const contactEmail of [undefined, "invalid", `${"a".repeat(190)}@example.com`]) {
+ test(`Contact Us confirmation rejects invalid email ${String(contactEmail).slice(0, 30)}`, async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({
+ body: { ...validContactConfirmationRequest().body, contactEmail }
+ }),
+ status: 400,
+ code: "INVALID_CONTACT_EMAIL"
+ });
+ });
+}
+
+for (const typeOfContact of [undefined, "unsupported"]) {
+ test(`Contact Us confirmation rejects category ${String(typeOfContact)}`, async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({
+ body: { ...validContactConfirmationRequest().body, typeOfContact }
+ }),
+ status: 400,
+ code: "INVALID_CONTACT_TYPE"
+ });
+ });
+}
+
+for (const contactbody of [undefined, " ", "a".repeat(801)]) {
+ test(`Contact Us confirmation rejects message ${String(contactbody).slice(0, 20)}`, async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({
+ body: { ...validContactConfirmationRequest().body, contactbody }
+ }),
+ status: 400,
+ code: "INVALID_CONTACT_BODY"
+ });
+ });
+}
+
+test("Contact Us confirmation rejects a subject not matching its category", async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({
+ body: {
+ ...validContactConfirmationRequest().body,
+ contactSubject: contactSubjects.plq.en
+ }
+ }),
+ status: 400,
+ code: "INVALID_CONTACT_SUBJECT"
+ });
+});
+
+for (const language of [undefined, "CY", "fr", ""]) {
+ test(`Contact Us confirmation rejects language ${String(language)}`, async () => {
+ await assertContactConfirmationRejected({
+ req: validContactConfirmationRequest({
+ body: { ...validContactConfirmationRequest().body, language }
+ }),
+ status: 400,
+ code: "INVALID_LANGUAGE"
+ });
+ });
+}
+
+test("Contact Us confirmation selects English template and exact safe personalisation", async () => {
+ await withContactOrigins(async () => {
+ const loaded = loadContactConfirmationEndpoint();
+ const req = validContactConfirmationRequest({
+ body: {
+ ...validContactConfirmationRequest().body,
+ contactbody:
+ "Please review (https://example.invalid/path?q=1#part), then email applicant@example.com about APP/123456."
+ }
+ });
+ const originalBody = JSON.parse(JSON.stringify(req.body));
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.notifyCalls.length, 1);
+ assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateEn);
+ assert.strictEqual(loaded.notifyCalls[0][1], "applicant@example.com");
+ assert.deepStrictEqual(JSON.parse(JSON.stringify(loaded.notifyCalls[0][2])), {
+ personalisation: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.ipq.en,
+ contactbody:
+ "Please review ([web link removed]), then email applicant@example.com about APP/123456.",
+ typeOfContact: "ipq"
+ },
+ reference: "PEDW-CONTACT-US"
+ });
+ assert.deepStrictEqual(req.body, originalBody);
+ });
+});
+
+test("Contact Us confirmation selects Welsh template and supports an 800-character message", async () => {
+ await withContactOrigins(async () => {
+ const loaded = loadContactConfirmationEndpoint();
+ const message = "a".repeat(800);
+ const req = validContactConfirmationRequest({
+ body: {
+ contactEmail: "applicant@example.com",
+ contactSubject: contactSubjects.plq.cy,
+ contactbody: message,
+ typeOfContact: "plq",
+ language: "cy"
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(req, res);
+
+ assert.strictEqual(res.state.statusCode, 200);
+ assert.strictEqual(loaded.notifyCalls[0][0], contactConfirmationTemplateCy);
+ assert.strictEqual(loaded.notifyCalls[0][2].personalisation.contactbody, message);
+ });
+});
+
+test("Contact Us confirmation returns a bounded error when Notify fails", async () => {
+ await withContactOrigins(async () => {
+ let sendCalls = 0;
+ const loaded = loadContactConfirmationEndpoint({
+ sendEmail: async () => {
+ sendCalls += 1;
+ throw new Error("notify failed");
+ }
+ });
+ const res = createRes();
+
+ await loaded.mod.default(validContactConfirmationRequest(), res);
+
+ assert.strictEqual(sendCalls, 1);
+ assert.strictEqual(res.state.statusCode, 400);
+ assert.strictEqual(
+ res.state.jsonBody.error.code,
+ "CONTACT_US_CONFIRMATION_FAILED"
+ );
+ });
+});
+
const run = async () => {
let passed = 0;
for (const currentTest of tests) {
diff --git a/tests/phase7/service-behaviour.test.cjs b/tests/phase7/service-behaviour.test.cjs
index e53a3071..270292a1 100644
--- a/tests/phase7/service-behaviour.test.cjs
+++ b/tests/phase7/service-behaviour.test.cjs
@@ -208,7 +208,21 @@ test("document delete blob flows use signer hash", async () => {
axios,
BASE_URL: "",
consoleLogger: logger.consoleLogger,
- hashAPIPath: () => "&hash=fallback"
+ hashAPIPath: () => "&hash=fallback",
+ postSignedFileJson: async (queryUrl, data, config = {}) => {
+ const hashResponse = await axios.get(
+ "/api/endpoint/gethash_api?path=" + encodeURIComponent(queryUrl)
+ );
+
+ const response = await axios({
+ method: "post",
+ url: queryUrl + hashResponse.data.hash,
+ data,
+ ...config
+ });
+
+ return response.data;
+ }
});
const one = await document.deleteAwaitingSubmissionsFromBlob(
@@ -384,6 +398,68 @@ test("notify/sendEmail logs and rethrows on failure", async () => {
assert.strictEqual(logger.calls.length, 1);
});
+test("notify/sendContactUsConfirmation posts only the dedicated payload", async () => {
+ const axios = createAxiosMock();
+ const logger = createLoggerMock();
+
+ axios.requestHandler = async () => ({ data: { sent: true } });
+
+ const notify = loadServiceModule("notifyDirectService.js", {
+ axios,
+ consoleLogger: logger.consoleLogger
+ });
+ const formValues = {
+ contactEmail: "person@example.com",
+ contactSubject: "Questions about Planning legislation or policy",
+ contactbody: "Please review https://example.invalid",
+ typeOfContact: "plq",
+ ignored: "not sent"
+ };
+
+ const result = await notify.sendContactUsConfirmation(formValues, "cy");
+
+ assert.deepStrictEqual(normalize(result), { sent: true });
+ assert.strictEqual(
+ axios.calls[0].config.url,
+ "/api/email/contactus-confirmation"
+ );
+ assert.strictEqual(axios.calls[0].config.method, "post");
+ assert.deepStrictEqual(normalize(axios.calls[0].config.data), {
+ contactEmail: "person@example.com",
+ contactSubject: "Questions about Planning legislation or policy",
+ contactbody: "Please review https://example.invalid",
+ typeOfContact: "plq",
+ language: "cy"
+ });
+});
+
+test("notify/sendContactUsConfirmation logs and rethrows on failure", async () => {
+ const axios = createAxiosMock();
+ const logger = createLoggerMock();
+ const error = createAxiosError(400, "Bad Request");
+
+ axios.requestHandler = async () => Promise.reject(error);
+
+ const notify = loadServiceModule("notifyDirectService.js", {
+ axios,
+ consoleLogger: logger.consoleLogger
+ });
+
+ await assert.rejects(
+ notify.sendContactUsConfirmation(
+ {
+ contactEmail: "person@example.com",
+ contactSubject: "Other enquiry for PEDW",
+ contactbody: "Message",
+ typeOfContact: "other"
+ },
+ "en"
+ ),
+ (thrown) => thrown === error
+ );
+ assert.strictEqual(logger.calls.length, 1);
+});
+
test("integration/createCRMTask posts payload and returns data", async () => {
const axios = createAxiosMock();
const logger = createLoggerMock();