Skip to content
Merged
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
126 changes: 57 additions & 69 deletions api/SubmitContact/index.mjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,16 @@
import fetch from "node-fetch";
import FormData from "form-data";

async function assess(token, ip) {
/**
* Fetch the reCAPTCHA score for the user's submission, as evaulated by Google.
* @param token {string} reCAPTCHA token to evaluate.
* @param ip {string|undefined} IP address of the user.
* @returns {Promise<unknown>} JSON response from Google reCAPTCHA v3 verify API. This response is expected to have at
* least a floating point "score" value in the root level of the JSON.
* @see {@link https://developers.google.com/recaptcha/docs/verify} Google reCAPTCHA verify API documentation
* @see {@link https://developers.google.com/recaptcha/docs/v3} Google reCAPTCHA v3 verify API response definition
*/
async function fetchGoogleCaptchaScore(token, ip) {
let body = `response=${token}&secret=${process.env.RecaptchaSecret}`;
if (ip) {
body += `&remoteip=${ip}`;
Expand All @@ -13,6 +22,34 @@ async function assess(token, ip) {
});
}

/**
* Generate an error message HTTP response for the client. This Response can be returned from an Azure Function
* directly.
* @param code {number} HTTP status code to return.
* @param message {string} Error message to return.
* @returns {{httpResponse: {status: number, body: {error: string}}}}
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Response} JavaScript HTTP Response object definition
*/
function createError(code, message) {
return {
httpResponse: {
status: code,
body: { error: message },
},
};
}

/**
* Send an email using Mailgun API.
* @param name {string} Name of the person sending the email.
* @param email {string} Email address of the person sending the email.
* @param subject {string} Subject of the email.
* @param body {string} Body of the email.
* @returns {Promise<void>} Promise that resolves when the request to Mailgun to send an email is complete.
* @throws Error if the email could not be sent.
* @see {@link https://documentation.mailgun.com/docs/mailgun/api-reference/send/mailgun/messages/post-v3--domain-name--messages}
* Mailgun API documentation
*/
async function sendEmail(name, email, subject, body) {
const form = new FormData();
form.append("from", `${name} <${process.env.SenderEmail}>`);
Expand All @@ -39,12 +76,7 @@ async function sendEmail(name, email, subject, body) {

export default async function (context, req) {
if (!req.body) {
return {
httpResponse: {
status: 400,
body: { error: "Missing contact form submission." },
},
};
return createError(400, "Missing contact form submission.");
}

let name = req.body.name;
Expand All @@ -54,69 +86,37 @@ export default async function (context, req) {
let token = req.body.token;

if (!name || !email || !subject || !body) {
return {
httpResponse: {
status: 400,
body: { error: "Missing required fields." },
},
};
return createError(400, "Missing required fields.");
}

if (!token) {
return {
httpResponse: {
status: 401,
body: { error: "Missing Google Recaptcha token" },
},
};
return createError(401, "Missing Google Recaptcha token.");
}

let assessment;
let score;
try {
assessment = await assess(token, req.headers["x-forwarded-for"]);
const assessment = await fetchGoogleCaptchaScore(
token,
req.headers["x-forwarded-for"]
);
const body = await assessment.json();
if (!body.success) {
context.log(
"Received reCAPTCHA error codes: " + body["error-codes"].join(", ")
);
return {
httpResponse: {
status: 500,
body: {
error:
"Received reCAPTCHA error codes: " +
body["error-codes"].join(", "),
},
},
};
const errorMessage =
"Received reCAPTCHA error codes: " + body["error-codes"].join(", ");
context.log(errorMessage);
return createError(500, errorMessage);
}
if (body.action !== "contactSubmit") {
context.log("Invalid reCAPTCHA action received");
return {
httpResponse: {
status: 400,
body: { error: "Invalid reCAPTCHA action" },
},
};
return createError(400, "Invalid reCAPTCHA action received");
}
score = body.score;
} catch (err) {
context.log(err);
return {
httpResponse: {
status: 500,
body: { error: "reCAPTCHA error" },
},
};
return createError(500, "reCAPTCHA error");
}
if (score < 0.7) {
return {
httpResponse: {
status: 403,
body: { error: "Recaptcha failed. Try again, or come back later." },
},
};
return createError(403, "Recaptcha failed. Try again, or come back later.");
}

name = name.substring(0, 300);
Expand All @@ -125,29 +125,17 @@ export default async function (context, req) {
body = body.substring(0, 2000);

if (subject.length < 5 || body.length < 20) {
return {
httpResponse: {
status: 400,
body: {
error:
"subject must be between 5 and 100 characters, and body must be between 20 and 2000 characters.",
},
},
};
return createError(
400,
"subject must be between 5 and 100 characters, and body must be between 20 and 2000 characters."
);
}

try {
await sendEmail(name, email, subject, body);
} catch (err) {
console.error(err);
return {
httpResponse: {
status: 500,
body: {
error: "Failed to send email. Please try again later.",
},
},
};
return createError(500, "Failed to send email. Please try again later.");
}

return {
Expand Down