diff --git a/app/(gcforms)/[locale]/layout.tsx b/app/(gcforms)/[locale]/layout.tsx index e975844ba1..0fb8b344e9 100644 --- a/app/(gcforms)/[locale]/layout.tsx +++ b/app/(gcforms)/[locale]/layout.tsx @@ -1,6 +1,7 @@ import { auth } from "@lib/auth"; import { ClientContexts } from "@clientComponents/globals/ClientContexts"; import { ReactHydrationCheck } from "@clientComponents/globals"; +import { NotifyCatcher } from "@lib/notifyCatcher/NotifyCatcher"; import { checkAll } from "@lib/cache/flags"; export default async function Layout({ children }: { children: React.ReactNode }) { @@ -13,6 +14,7 @@ export default async function Layout({ children }: { children: React.ReactNode } {children} + {process.env.APP_ENV === "local" && } ); } diff --git a/lib/integration/notifyConnector.ts b/lib/integration/notifyConnector.ts index 2f4f5eb185..28faeb2489 100644 --- a/lib/integration/notifyConnector.ts +++ b/lib/integration/notifyConnector.ts @@ -1,6 +1,7 @@ import { GCNotifyConnector, type Personalisation } from "@gcforms/connectors"; import { logMessage } from "@lib/logger"; import { traceFunction } from "../otel"; +import { notifyCatcher } from "../notifyCatcher"; const gcNotifyConnector = GCNotifyConnector.default(process.env.NOTIFY_API_KEY ?? ""); export const sendEmail = async (email: string, personalisation: Personalisation, type: string) => { @@ -16,6 +17,11 @@ export const sendEmail = async (email: string, personalisation: Personalisation, throw new Error("No Notify template ID configured."); } + if (process.env.APP_ENV === "local") { + notifyCatcher(email, personalisation); + return; + } + await gcNotifyConnector.sendEmail(email, templateId, personalisation); logMessage.info("HealthCheck: send email success"); diff --git a/lib/notifyCatcher/NotifyCatcher.tsx b/lib/notifyCatcher/NotifyCatcher.tsx new file mode 100644 index 0000000000..df32da6965 --- /dev/null +++ b/lib/notifyCatcher/NotifyCatcher.tsx @@ -0,0 +1,259 @@ +"use client"; +import { useEffect, useRef, useState } from "react"; +import { fetchMessages, resetMessages, deleteMessageByIndex } from "./actions"; +import Markdown from "markdown-to-jsx"; + +interface Message { + email: string; + personalisation: { + subject: string; + formResponse: string; + }; +} + +export const NotifyCatcher = () => { + const ref = useRef(null); + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [visible, setVisible] = useState(false); + + const getMessages = async () => { + setLoading(true); + const messages = await fetchMessages(); + setMessages(messages); + setLoading(false); + }; + + const open = () => { + getMessages(); + setVisible(true); + }; + + const handleClickOutside = (event: MouseEvent) => { + if (ref.current && !ref.current.contains(event.target as Node)) { + setVisible(false); + } + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + setVisible(false); + } + }; + + const clearMessages = async () => { + resetMessages(); + setMessages([]); + }; + + const deleteMessage = async (index: number) => { + await deleteMessageByIndex(index); + setMessages(messages.filter((_, i) => i !== index)); + }; + + const is2FAMessage = (message: Message): boolean => { + return ( + message.personalisation.subject.includes("Your security code") || + message.personalisation.subject.includes("Votre code de sécurité") + ); + }; + + const extract2FACode = (message: Message): string | null => { + if (!is2FAMessage(message)) return null; + // Extract the code - it's typically a short alphanumeric string + const lines = message.personalisation.formResponse.split("\n").filter((line) => line.trim()); + // The code is usually the last non-empty line or a short alphanumeric string + for (const line of lines) { + const trimmed = line.trim(); + // Look for a short code (typically 4-6 characters, alphanumeric) + if (trimmed.length >= 4 && trimmed.length <= 8 && /^[A-Za-z0-9]+$/.test(trimmed)) { + return trimmed; + } + } + return null; + }; + + const copyCodeToClipboard = async (code: string) => { + try { + await navigator.clipboard.writeText(code); + } catch (err) { + // Ignore clipboard errors + } + }; + + useEffect(() => { + getMessages(); + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("keydown", handleKeyDown); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("keydown", handleKeyDown); + }; + }, []); + + return ( + <> + {!visible && ( +
open()} + title={`${messages.length} intercepted message${messages.length !== 1 ? "s" : ""}`} + > + + + + {messages.length} +
+ )} + {visible && ( +
+ {loading ? ( +
Loading...
+ ) : ( + <> +
+
+ + + +

Notify Intercept

+ + {messages.length} + +
+
+ {messages.length > 0 && ( + + )} + + +
+
+
+ {messages.length === 0 && ( +
+ No messages to show +
+ )} + {messages && + messages.map((message, index) => { + const securityCode = extract2FACode(message); + return ( +
+
+
+
To:
+
{message.email}
+
Subject:
+
{message.personalisation.subject}
+
+ +
+
+ {securityCode ? ( +
+
+ + Security Code + +
+ {securityCode} +
+
+ +
+ ) : null} +
+ + {message.personalisation.formResponse} + +
+
+
+ ); + })} +
+ + )} +
+ )} + + ); +}; diff --git a/lib/notifyCatcher/actions.ts b/lib/notifyCatcher/actions.ts new file mode 100644 index 0000000000..eaaa416b03 --- /dev/null +++ b/lib/notifyCatcher/actions.ts @@ -0,0 +1,24 @@ +"use server"; +import { getRedisInstance } from "@lib/integration/redisConnector"; + +export const fetchMessages = async () => { + const redis = await getRedisInstance(); + const emails = await redis.lrange("notifyEmails", 0, -1); + return emails.map((email) => JSON.parse(email)); +}; + +export const resetMessages = async () => { + const redis = await getRedisInstance(); + await redis.del("notifyEmails"); +}; + +export const deleteMessageByIndex = async (index: number) => { + const redis = await getRedisInstance(); + const emails = await redis.lrange("notifyEmails", 0, -1); + if (index >= 0 && index < emails.length) { + // Remove the specific item by setting it to a placeholder, then removing all occurrences + const placeholder = "__DELETE_PLACEHOLDER__" + Date.now(); + await redis.lset("notifyEmails", index, placeholder); + await redis.lrem("notifyEmails", 1, placeholder); + } +}; diff --git a/lib/notifyCatcher/index.ts b/lib/notifyCatcher/index.ts new file mode 100644 index 0000000000..ac1335c225 --- /dev/null +++ b/lib/notifyCatcher/index.ts @@ -0,0 +1,18 @@ +import { type Personalisation } from "@gcforms/connectors"; +import { getRedisInstance } from "@lib/integration/redisConnector"; +import { logMessage } from "@lib/logger"; + +export const notifyCatcher = async (email: string, personalisation: Personalisation) => { + try { + const redis = await getRedisInstance(); + await redis.lpush("notifyEmails", JSON.stringify({ email, personalisation })); + } catch (e) { + logMessage.error(`NotifyCatcher failed to catch the email: ${(e as Error).message}`); + + // just log it out + logMessage.info("Development Notify email sending"); + logMessage.info("To: " + email); + logMessage.info("Subject: " + personalisation.subject); + logMessage.info("Body: " + personalisation.formResponse); + } +}; diff --git a/tailwind.config.ts b/tailwind.config.ts index 2e270ee948..2f8c803f25 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -217,6 +217,10 @@ module.exports = { xxs: { max: "290px" }, }, }, - content: ["./app/**/*.{js,ts,jsx,tsx,mdx}", "./components/**/*.{js,ts,jsx,tsx,mdx}"], + content: [ + "./app/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./lib/notifyCatcher/**/*.{ts,tsx}", + ], plugins: [], } satisfies Config;