# Indirect Prompt Injection via Email

Indirect prompt injection via email hides instructions in a message an assistant later reads. EchoLeak, the inbox path, and tested TypeScript guards.

Author: Formgong team
Published: 2026-10-06
Updated: 2026-10-06
Language: en
Canonical: https://formgong.com/en/blog/indirect-prompt-injection-via-email/

Indirect prompt injection via email starts when someone sends a message. Hidden HTML or CSS can carry instructions. An assistant that later reads, summarizes, or acts on the inbox can follow them. It may leak data through a link or image, send mail, or change a verdict. Checking the sender does not check that text.

## How does indirect prompt injection via email work?

Indirect prompt injection via email has five hops. The marker is `[PLACEHOLDER INSTRUCTION]`. It is not a payload.

HopWhat happens

1. SendThe marker sits in the body, a signature, or a hidden style.
2. DeliverThe mailbox accepts it. SPF, DKIM, and DMARC can all pass.
3. IngestAn assistant loads that message from the inbox.
4. ReadThe model may follow the marker.
5. ActA summary, a link, an image fetch, a send, or a new verdict.

SPF checks the sending server. DKIM checks a signature. DMARC checks that those two agree with the From domain. A domain owner, or a stolen account, can pass all three. None of them reads the words.

A gateway can block a known bad link and still pass a normal note. Immersive Labs (21 July 2025, updated 21 July 2026) showed hidden HTML that rebuilt a link. The gateway had not seen that link as one string. Hosts here are `attacker.example`.

[SucuriLabs](https://sucurilabs.com/blog/how-email-used-prompt-injection) makes the same split: authentication names the sender, and the app still decides what the assistant may do. The definition stays on [Indirect Prompt Injection in MCP](/en/blog/indirect-prompt-injection-mcp/). Dated cases are on [Indirect Prompt Injection Examples (2023–2026)](/en/blog/indirect-prompt-injection-examples/).

## Prompt injection in email: where the instruction hides

Prompt injection in email often hides beside a normal note. The person sees the note. The model receives the HTML. Each spot below matches a primary source from 06.10.2026. The marker never changes.

Styles a person does not see
These lines are defanged. Do not send them.

`display:none
visibility:hidden
font-size:0
color:#ffffff on background:#ffffff
mso-hide:all
<!-- [PLACEHOLDER INSTRUCTION] -->
https://attacker.example/log`
Immersive Labs, Insinuator (2 September 2025), Forcepoint (25 August 2026), and 0DIN (10 July 2025) used these tricks. The model still got the raw markup.

Quoted replies, signatures, and footers
A quoted reply, a signature, and a footer are visible. They can still hold the marker. Immersive Labs put lab text in a signature. Hiding is not required.

Attachments and calendar invites
Proofpoint (18 November 2025, updated 20 February 2026) names the body, an attachment, white text, and metadata. Google (13 June 2025) names emails, documents, and calendar invites. Radware (18 September 2025) names Outlook and Google invites with hidden HTML. No further attachment case is added here.

Payload fragmentation
Immersive Labs split a link so a scanner would miss the full URL. The joined host here would be `attacker.example`.

## The form-notification path

A contact form is another door into that inbox. The visitor types a message, the host emails the owner, and an assistant summarizes the mail.

Form notification reaches an inbox assistant
A visitor submits a form. The backend stores the fields and sends a notification. The HTML part is escaped. The plain text is still the visitor's words. An assistant reads the inbox. A send is allowed only when the recipient is on the list.

1. Visitor writes the form
name, email, and message

2. Backend stores the fields
the text is still data

3. Notification mail
HTML escaped, words kept

4. Assistant reads the inbox
summary or a draft reply

5. Code checks the send
allow-list, exact recipient

Visitor words travel in the notification. Escaped HTML blocks markup. It does not block a later assistant.

Formgong’s notification HTML escapes each field. Angle brackets, ampersands, and quotes become entities. A visitor cannot add a tag or a style. Hidden CSS from the form does not survive.

The plain-text part keeps the visitor’s words, one line per field. Bidi and zero-width characters become a visible `[U+XXXX]` mark.

On the free plan, email is a daily digest at 08:00 in your time zone, for the previous day. Telegram is instant on every plan.

If a summary can send mail, the form sentence can name a recipient. Stop that in the send tool. Page setup is on [Contact form not sending email](/en/blog/contact-form-not-sending-email/). Telegram delivery is on [Telegram bot for a contact form](/en/blog/telegram-bot-for-contact-form/).

## How EchoLeak turned one email into data exfiltration

EchoLeak is Aim Labs’ name for CVE-2025-32711 in Microsoft 365 Copilot. The short row and “What is EchoLeak?” stay on [Indirect Prompt Injection Examples (2023–2026)](/en/blog/indirect-prompt-injection-examples/).

NVD, fetched on 06.10.2026, lists two CVSS 3.1 scores. The CNA score is 9.3, critical. NVD’s own score is 7.5, high. CNA scope is changed and integrity is low. NVD scope is unchanged and integrity is none. The CNA vector is `AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N`. The NVD vector is `AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N`. This page does not pick one.

MSRC matches 9.3, critical, under “M365 Copilot Information Disclosure Vulnerability,” published 11 June 2025. `exploited` is No. No customer action is required. Microsoft says it was already fully mitigated. Aim Labs says no customers were affected, and names no May or June patch day. [Simon Willison’s 11 June 2025 note](https://simonwillison.net/2025/Jun/11/echoleak/) says the January report’s fix was already rolled out.

The XPIA classifier bypass
XPIA means cross-prompt injection attack. Aim Labs says the classifiers were meant to stop injection text. The lab email spoke to the recipient. It never named AI, an assistant, or Copilot. That wording got past the classifier.

Reference-style Markdown
Ordinary Markdown links were redacted. Reference-style links were not. Here the URL is only `https://attacker.example/log`.

Auto-fetched images
A link needs a click. An image does not. A reference-style image skipped redaction and put Copilot context in the URL.

A Teams proxy the page already allowed
The image policy blocked `attacker.example` and allowed `*.teams.microsoft.com`. A Teams URL fetched another host for the client. The user did not accept an invite. This page does not print that path.

The server-side fix
There is no customer update to install. A chat-box filter would not have seen this email. Copilot retrieved it later, while answering some other question.

## Other email prompt injection attacks

The cases below are the ones a primary page still supported on 06.10.2026.

Insinuator and an AI mail client on Gmail
On 2 September 2025, Florian Port, Malte Heinzelmann, and Florian Grunow tested Shortwave on Google Mail. Hidden HTML still reached the model. “Organize my inbox” and “Find urgent emails” run in bulk, and the bad message stays closed. `read_webpage` could send data to an attacker page. “Don’t ask again” is offered, and they call that likely. Reported 22 July 2025, confirmed the next day, gates on 14 August 2025. Most proposed changes were in. `read_webpage` was still open.

ShadowLeak and ChatGPT Deep Research
Radware’s 18 September 2025 post is by Zvika Babo and Gabi Nakibly, with Maor Uziel. One email made ChatGPT Deep Research leak Gmail data. Browsing was on. There was no further click. The fetch ran in OpenAI’s cloud, which they call service-side. Tiny fonts and white-on-white text hid the instructions. Success was 100 percent on the attack they tested. Bugcrowd received it on 18 June 2025, plus a variant the next day. OpenAI fixed it in early August, with no note to Radware, and resolved it on 3 September 2025. Outlook mail and calendar invites are named too.

Gemini email summaries
HiddenLayer (25 September 2024), by Jason Martin and Kenneth Yeung, covers Gemini for Workspace. White Gmail text and Slides speaker notes could steer a summary.

They write that Google “decided not to track it as a security issue and marked the ticket as ‘Won’t Fix (Intended Behavior)’.”

0DIN’s “Phishing For Gemini” (10 July 2025) is the same product. White-on-white text, or a zero font size, rides along with “Summarize this email.” Gemini then appends a warning. It looks as if Google wrote it. No link or attachment is required. The idea was public in 2024, mitigations were already out, and it still worked.

Google’s layered defense and the DeepMind paper
Google’s 13 June 2025 post names classifiers, security thought reinforcement, Markdown sanitizing, URL redaction, a confirm step, and a notice. External images are not rendered, so EchoLeak’s image fetch does not apply to Gemini. “More than 99.9 percent” is spam, phishing, and malware, not prompt injection. Google does not call the class closed.

DeepMind’s arXiv:2505.14534, submitted 20 May 2025, summarizes the latest email, which the sender can steer. Training alone does not fully solve this. Adversarial training is not sufficient by itself. Defense in depth is necessary.

Two lab proofs of concept
Immersive Labs (21 July 2025) hid a fragmented link in a signature. The CSS was `font-size:0`, white text, and `mso-hide`. A summary rebuilt the link. The gateway missed the pieces.

Forcepoint X-Labs (25 August 2026) used an unguarded Outlook add-in and `claude-haiku-4-5` at temperature 0. They say it was not an attack on Outlook or that model. Hidden CSS was `font-size:0px`, white, and `line-height:0`. The mail showed 537 characters. The model saw 1,009, of which 472 were the injection. Ten trials each. The heading is “Injection Succeeded in All 10 Runs.”

Outcome they scoredCleanInjected

Summary states EUR 46,2000/1010/10
Summary gives 3 Sep 2026 / 14:000/1010/10
Summary names the person in the visible mail10/100/10
Summary states the 21 Aug 2026 deadline10/100/10

The injected amount was more than five times the real figure. Latency moved by about 0.5 to 1 second. None of those summaries mentioned the hidden notice. It is a lab test, not a field incident.

## CVE-2024-5184 (EmailGPT) is direct injection

**Different bug.** [CVE-2024-5184](https://www.cve.org/CVERecord?id=CVE-2024-5184) is EmailGPT, and it is direct. NVD, fetched on 06.10.2026, says a user of the service can inject a prompt and take over the logic. The attacker is not hiding text in someone else’s inbox.

The CNA scores are CVSS 3.1 at 6.5, medium, and CVSS 4.0 at 8.5, high. NVD’s own CVSS 3.1 score is 9.1, critical. The examples page keeps this row out of the indirect table: [Indirect Prompt Injection Examples (2023–2026)](/en/blog/indirect-prompt-injection-examples/).

## How to stop prompt injection through email

The tests run `examples/email-to-model-guard.ts`, shown below. No socket, no model call. You pass the HTML, the allow-list, and the Markdown. Three checks run in your code.

- **Visible text.** `extractVisibleEmailText` returns the words a person would see, plus flags. It drops `display:none`, `visibility:hidden`, `mso-hide:all`, a zero font, up to 1px, and same-color text. It also drops HTML comments, zero-width characters, and bidi controls. A flag means the two texts disagree. Do not auto-run a tool.

- **Recipients.** `checkSendRecipient` checks `to`, `cc`, and `bcc` against a host allow-list. Mail text cannot add an address. An address off the list blocks the call.

- **Output sinks.** `stripUnlistedOutput` removes Markdown images and links, including reference-style ones, whose host is not allowed. That is the EchoLeak sink. An image to `attacker.example` is deleted so the browser does not fetch it.

A filter is a signal, not a boundary. A visible signature line can still be obeyed. A summary needs no send tool. A send checks the exact recipient in code, and a person approves that call only. A new address needs a new yes. Egress stays on an allow-list. The collapsed sample is an output shape, not a request.

A check for any outbound URL, host, or recipient, on any tool, is on [step-by-step prevention with a TypeScript policy layer](/en/blog/indirect-prompt-injection-prevention/). This page keeps the email-specific guard.

```typescript
/**
 * Keep an email from becoming a model instruction.
 *
 * extractVisibleEmailText returns the text a person would see, plus flags
 * for hidden markup that was dropped. checkSendRecipient refuses any
 * send_email address that is not on the host allow-list. The model cannot
 * override that list: this function never reads a model message.
 * stripUnlistedOutput removes Markdown images and links whose host is not
 * on the allow-list. This file does not open a network connection and does
 * not call a model.
 */

export const HIDDEN_FLAGS = [
  "display-none",
  "visibility-hidden",
  "near-zero-font",
  "same-color-text",
  "mso-hide",
  "html-comment",
  "zero-width",
  "bidi-override",
] as const;

export type HiddenFlag = (typeof HIDDEN_FLAGS)[number];

export type VisibleEmail = {
  text: string;
  flags: HiddenFlag[];
};

export type SendEmailCall = {
  to?: unknown;
  cc?: unknown;
  bcc?: unknown;
};

export type RecipientDecision =
  | { allowed: true; recipients: string[] }
  | { allowed: false; rejected: string[]; message: string };

export type RemovedOutput = {
  kind: "image" | "link";
  host: string;
};

export type OutputDecision = {
  text: string;
  removed: RemovedOutput[];
};

export class EmailGuardError extends Error {
  constructor(message: string) {
    super(message);
    this.name = "EmailGuardError";
  }
}

const ZERO_WIDTH = /[\u200B\u200C\u200D\u2060\uFEFF\u180E]/;
const BIDI = /[\u202A-\u202E\u2066-\u2069]/;
const EMAIL = /^[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$/;
const VOID_TAGS = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"]);
const SKIP_TAGS = new Set(["script", "style", "noscript", "head", "title"]);
const BLOCK_TAGS = new Set(["p", "div", "li", "tr", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", "pre", "section", "article", "table"]);

type Frame = {
  tag: string;
  hidden: boolean;
  color: string | null;
  background: string | null;
};

function requireString(value: unknown, label: string): string {
  if (typeof value !== "string") throw new EmailGuardError(`${label} must be a string.`);
  return value;
}

function normalizeColor(value: string): string | null {
  const raw = value.trim().toLowerCase().replace(/\s+/g, "");
  const named: Record<string, string> = {
    white: "#ffffff",
    black: "#000000",
    transparent: "transparent",
  };
  if (named[raw]) return named[raw];
  const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/.exec(raw);
  if (hex) {
    const body = hex[1]!;
    const full = body.length === 3 ? body.split("").map((part) => part + part).join("") : body;
    return `#${full}`;
  }
  const rgb = /^rgba?\((\d{1,3}),(\d{1,3}),(\d{1,3})(?:,[\d.]+)?\)$/.exec(raw);
  if (!rgb) return null;
  const parts = [rgb[1], rgb[2], rgb[3]].map((part) => Math.min(255, Number(part)).toString(16).padStart(2, "0"));
  return `#${parts.join("")}`;
}

function nearZeroFont(value: string): boolean {
  const match = /^(-?\d*\.?\d+)(px|pt|em|rem|%)?$/.exec(value.trim().toLowerCase());
  if (!match) return false;
  const amount = Number(match[1]);
  const unit = match[2] ?? "";
  if (!Number.isFinite(amount) || amount < 0) return false;
  if (amount === 0) return true;
  return unit === "px" && amount <= 1;
}

function styleFlags(style: string): { flags: HiddenFlag[]; color: string | null; background: string | null } {
  const flags: HiddenFlag[] = [];
  let color: string | null = null;
  let background: string | null = null;
  for (const decl of style.split(";")) {
    const split = decl.split(":");
    if (split.length < 2) continue;
    const prop = split[0]!.trim().toLowerCase();
    const value = split.slice(1).join(":").trim().toLowerCase();
    if (prop === "display" && value === "none") flags.push("display-none");
    if (prop === "visibility" && (value === "hidden" || value === "collapse")) flags.push("visibility-hidden");
    if (prop === "mso-hide" && value === "all") flags.push("mso-hide");
    if (prop === "font-size" && nearZeroFont(value)) flags.push("near-zero-font");
    if (prop === "color") color = normalizeColor(value);
    if (prop === "background-color") background = normalizeColor(value.split(" ")[0] ?? value);
    if (prop === "background") {
      const found = value.split(/\s+/).map(normalizeColor).find((item) => item && item !== "transparent");
      if (found) background = found;
    }
  }
  return { flags, color, background };
}

function decodeEntities(value: string): string {
  return value
    .replace(/&#x([0-9a-f]+);/gi, (_, hex: string) => safeCodePoint(Number.parseInt(hex, 16)))
    .replace(/&#(\d+);/g, (_, num: string) => safeCodePoint(Number(num)))
    .replace(/&nbsp;/gi, " ")
    .replace(/&quot;/gi, '"')
    .replace(/&#39;|&apos;/gi, "'")
    .replace(/&lt;/gi, "<")
    .replace(/&gt;/gi, ">")
    .replace(/&amp;/gi, "&");
}

function safeCodePoint(code: number): string {
  if (!Number.isFinite(code) || code < 0 || code > 0x10ffff) return "";
  try {
    return String.fromCodePoint(code);
  } catch {
    return "";
  }
}

function stripControls(value: string, flags: Set<HiddenFlag>): string {
  let next = value;
  if (ZERO_WIDTH.test(next)) {
    flags.add("zero-width");
    next = next.replace(new RegExp(ZERO_WIDTH, "g"), "");
  }
  if (BIDI.test(next)) {
    flags.add("bidi-override");
    next = next.replace(new RegExp(BIDI, "g"), "");
  }
  return next;
}

function readAttributes(source: string): { style: string; selfClosing: boolean } {
  let style = "";
  const selfClosing = /\/\s*$/.test(source.trim());
  const attr = /([:\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+))/g;
  let match: RegExpExecArray | null;
  while ((match = attr.exec(source))) {
    if (match[1]!.toLowerCase() !== "style") continue;
    style = match[2] ?? match[3] ?? match[4] ?? "";
  }
  return { style, selfClosing };
}

function collapseText(value: string): string {
  return value
    .replace(/[ \t]+\n/g, "\n")
    .replace(/[ \t]{2,}/g, " ")
    .replace(/\n{3,}/g, "\n\n")
    .trim();
}

/** Visible text from an HTML email, and the hide techniques that were dropped. */
export function extractVisibleEmailText(html: string): VisibleEmail {
  const source = requireString(html, "html");
  const flags = new Set<HiddenFlag>();
  const stack: Frame[] = [{ tag: "#root", hidden: false, color: null, background: null }];
  let text = "";
  let index = 0;

  const hiddenNow = () => stack.some((frame) => frame.hidden);
  const current = () => stack[stack.length - 1]!;

  while (index < source.length) {
    if (source.startsWith("<!--", index)) {
      flags.add("html-comment");
      const end = source.indexOf("-->", index + 4);
      index = end === -1 ? source.length : end + 3;
      continue;
    }
    if (source[index] === "<" && /^[a-zA-Z/]/.test(source[index + 1] ?? "")) {
      const end = source.indexOf(">", index + 1);
      if (end === -1) break;
      const raw = source.slice(index + 1, end).trim();
      index = end + 1;
      const closing = raw.startsWith("/");
      const name = (closing ? raw.slice(1) : raw).split(/\s+/)[0]?.toLowerCase().replace(/\/$/, "") ?? "";
      if (!name) continue;
      if (closing) {
        for (let depth = stack.length - 1; depth > 0; depth -= 1) {
          const frame = stack[depth]!;
          stack.pop();
          if (!frame.hidden && BLOCK_TAGS.has(frame.tag)) text += "\n";
          if (frame.tag === name) break;
        }
        continue;
      }
      const attrs = readAttributes(raw);
      const styled = styleFlags(attrs.style);
      for (const flag of styled.flags) flags.add(flag);
      const parent = current();
      const color = styled.color ?? parent.color;
      const background = styled.background ?? parent.background;
      let hidden = parent.hidden || styled.flags.length > 0 || SKIP_TAGS.has(name);
      if (color && background && color !== "transparent" && color === background) {
        flags.add("same-color-text");
        hidden = true;
      }
      if (!attrs.selfClosing && !VOID_TAGS.has(name)) {
        stack.push({ tag: name, hidden, color, background });
      } else if (!hidden && name === "br") {
        text += "\n";
      }
      continue;
    }
    const next = source.indexOf("<", index);
    const chunk = source.slice(index, next === -1 ? source.length : next);
    index = next === -1 ? source.length : next;
    if (!hiddenNow() && chunk) text += stripControls(decodeEntities(chunk), flags);
  }
  return { text: collapseText(text), flags: HIDDEN_FLAGS.filter((flag) => flags.has(flag)) };
}

function normalizeEmail(value: string): string {
  return value.trim().toLowerCase();
}

function readAddresses(value: unknown, label: string): string[] {
  if (value === undefined || value === null) return [];
  const list = Array.isArray(value) ? value : [value];
  return list.map((item, index) => {
    if (typeof item !== "string") throw new EmailGuardError(`${label}[${index}] must be a string.`);
    const email = normalizeEmail(item);
    if (!EMAIL.test(email)) throw new EmailGuardError(`${label}[${index}] is not an email address.`);
    return email;
  });
}

/**
 * Allow-list check for a send_email tool call.
 * `allowList` comes from host configuration. A model message cannot change it.
 */
export function checkSendRecipient(call: SendEmailCall, allowList: readonly string[]): RecipientDecision {
  if (!call || typeof call !== "object") throw new EmailGuardError("send_email call must be an object.");
  if (!Array.isArray(allowList)) throw new EmailGuardError("allowList must be an array.");
  const allowed = new Set(allowList.map((item, index) => {
    if (typeof item !== "string") throw new EmailGuardError(`allowList[${index}] must be a string.`);
    const email = normalizeEmail(item);
    if (!EMAIL.test(email)) throw new EmailGuardError(`allowList[${index}] is not an email address.`);
    return email;
  }));
  const recipients = [
    ...readAddresses(call.to, "to"),
    ...readAddresses(call.cc, "cc"),
    ...readAddresses(call.bcc, "bcc"),
  ];
  if (!recipients.length) {
    return { allowed: false, rejected: [], message: "send_email needs at least one recipient." };
  }
  const rejected = recipients.filter((email) => !allowed.has(email));
  if (rejected.length) {
    return {
      allowed: false,
      rejected,
      message: "Recipient is not on the allow-list. The model cannot add one.",
    };
  }
  return { allowed: true, recipients };
}

function hostOf(url: string): string | null {
  try {
    const parsed = new URL(url);
    if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "";
    return parsed.hostname.toLowerCase();
  } catch {
    return "";
  }
}

function allowedHost(host: string, allowHosts: ReadonlySet<string>): boolean {
  return host !== "" && allowHosts.has(host);
}

/**
 * Strip Markdown images, and links whose host is not on the allow-list.
 * Reference-style definitions are removed with the image or link that uses them.
 */
export function stripUnlistedOutput(markdown: string, allowHosts: readonly string[]): OutputDecision {
  const source = requireString(markdown, "markdown");
  if (!Array.isArray(allowHosts)) throw new EmailGuardError("allowHosts must be an array.");
  const hosts = new Set(allowHosts.map((host, index) => {
    if (typeof host !== "string" || host.trim() === "") throw new EmailGuardError(`allowHosts[${index}] must be a host name.`);
    return host.trim().toLowerCase().replace(/\.$/, "");
  }));
  const removed: RemovedOutput[] = [];
  const definitions = new Map<string, { host: string; url: string }>();
  const withoutDefinitions = source.replace(/^[ \t]{0,3}\[([^\]]+)\]:[ \t]*<?(https?:\/\/[^>\s]+)>?[^\n]*$/gim, (line, id: string, url: string) => {
    const host = hostOf(url) ?? "";
    definitions.set(id.trim().toLowerCase(), { host, url });
    if (!allowedHost(host, hosts)) {
      removed.push({ kind: "link", host: host || "unparsed" });
      return "";
    }
    return line;
  });
  return {
    text: replaceMarkdown(withoutDefinitions, definitions, hosts, removed).replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n").trim(),
    removed,
  };
}

function readBracket(source: string, start: number): { inner: string; end: number } | null {
  if (source[start] !== "[") return null;
  const end = source.indexOf("]", start + 1);
  if (end === -1) return null;
  return { inner: source.slice(start + 1, end), end: end + 1 };
}

function readParen(source: string, start: number): { inner: string; end: number } | null {
  if (source[start] !== "(") return null;
  let depth = 0;
  for (let index = start; index < source.length; index += 1) {
    if (source[index] === "(") depth += 1;
    else if (source[index] === ")") {
      depth -= 1;
      if (depth === 0) return { inner: source.slice(start + 1, index).trim(), end: index + 1 };
    }
  }
  return null;
}

function destinationHost(destination: string, definitions: Map<string, { host: string; url: string }>, label: string, inline: boolean): { host: string; known: boolean } {
  const trimmed = destination.trim();
  if (inline) {
    const url = trimmed.split(/\s+/)[0] ?? "";
    if (url.startsWith("http://") || url.startsWith("https://")) return { host: hostOf(url) ?? "", known: true };
    return { host: "", known: true };
  }
  const key = (trimmed || label).trim().toLowerCase();
  const found = definitions.get(key);
  if (!found) return { host: "", known: false };
  return { host: found.host, known: true };
}

function replaceMarkdown(
  source: string,
  definitions: Map<string, { host: string; url: string }>,
  hosts: ReadonlySet<string>,
  removed: RemovedOutput[],
): string {
  let out = "";
  for (let index = 0; index < source.length; index += 1) {
    const image = source.startsWith("![", index);
    const bracketAt = image ? index + 1 : index;
    if ((image || source[index] === "[") && source[index - 1] !== "!") {
      const label = readBracket(source, bracketAt);
      if (label) {
        const paren = source[label.end] === "(" ? readParen(source, label.end) : null;
        const ref = !paren && source[label.end] === "[" ? readBracket(source, label.end) : null;
        if (paren || ref) {
          const looked = destinationHost(paren ? paren.inner : ref!.inner, definitions, label.inner, Boolean(paren));
          const end = paren ? paren.end : ref!.end;
          if (!looked.known) {
            out += source.slice(index, end);
            index = end - 1;
            continue;
          }
          if (allowedHost(looked.host, hosts)) {
            out += source.slice(index, end);
          } else {
            removed.push({ kind: image ? "image" : "link", host: looked.host || "unparsed" });
            if (!image) out += label.inner;
          }
          index = end - 1;
          continue;
        }
      }
    }
    out += source[index];
  }
  return out;
}

```

**What the module printed for a hidden marker and a blocked send**

```text
visible text:
Please quote the patio.

flags:
display-none

send_email to stranger@attacker.example:
blocked, not on the allow-list

model output:
See the note.
removed image host: attacker.example
```

## Checklist for wiring Gmail or Outlook into an AI agent

- Give the summary a read scope. Do not put send on that token.

- Pass visible text and hide flags. Keep raw HTML away from the model.

- If a hide flag is set, do not auto-run tools. Show a person the mismatch.

- Allow-list `to`, `cc`, and `bcc` in code. The model cannot extend the list.

- Approve the exact recipient and body. One yes does not cover the next call.

- Strip images and links to any host you did not allow, before the reply renders.

- Skip bulk inbox jobs unless a person opened that message.

- Keep SPF, DKIM, and DMARC for sender checks. A pass is not a clean prompt.

- Log the tool name and the recipient. Do not copy the full message to a shared log.

- Treat a form notification as the same untrusted text. Escaped HTML stops markup, not the assistant.

## What Formgong does about this

Formgong is a free form backend for static and AI-built sites that delivers submissions to Telegram and email, stores data in the EU, and works in 12 languages. On the free plan, email is a digest at 08:00 the next morning. Telegram is instant. Paid plans can email each submission. That does not make prompt injection impossible.

Notification HTML escapes fields, as above. Telegram is HTML with those fields escaped, so they cannot become tags. Both channels still carry the visitor’s words. No model sits on delivery.

The [MCP server](/en/docs/mcp/), checked on 06.10.2026 against this repo and [llms.txt](https://formgong.com/llms.txt), uses authorization code with PKCE S256. A token is issued only for this server’s MCP URL. Reading submissions needs the opt-in scope `submissions:read`. The tool says those fields are untrusted data, never instructions, and the result repeats the notice. No MCP tool sends mail.

The notice is a label. Gmail or Outlook beside it is another token, so the text can still sit in context. Plans are on the [pricing page](/en/pricing/). Other injection types are on [Types of Injection Attacks on Web Forms (2026)](/en/blog/injection-taxonomy-2024-2026/). Write to [support@formgong.com](mailto:support@formgong.com).

## Frequently asked questions

### Can an email contain a prompt injection?

Yes. Instructions can sit in the body, in hidden HTML or CSS, in a signature, or in text loaded later. EchoLeak, CVE-2025-32711, was one such email to Microsoft 365 Copilot. SPF, DKIM, and DMARC do not remove it.

### What is an email prompt injection example?

A visible note says please quote the patio. A hidden block holds [PLACEHOLDER INSTRUCTION]. A summarizer that receives the raw HTML can follow that block. This page uses only that marker and attacker.example.

### Does Gemini have email prompt injection protection?

Google’s 13 June 2025 post names classifiers, security thought reinforcement, Markdown sanitizing, URL redaction, a confirm step, and a notice. External images are not rendered, so EchoLeak’s fetch does not apply. Google does not call the class closed. DeepMind’s 20 May 2025 paper says training alone is not a full fix.

### Do SPF, DKIM, and DMARC stop prompt injection?

No. They check the sender and the signature. An allowed domain can still carry instructions. Treat the body as data, and let the send tool enforce its own allow-list.

### What is an email prompt injection attack?

It is the five-hop path on this page. Mail is accepted, an assistant ingests it, and the model follows text inside it. A tool or a rendered link can then send data out. The weak point is tool scope, not the spam check.

## Sources and documentation

- [NVD: CVE-2025-32711 (fetched 6 October 2026)](https://nvd.nist.gov/vuln/detail/CVE-2025-32711)
- [MSRC: CVE-2025-32711](https://msrc.microsoft.com/update-guide/vulnerability/CVE-2025-32711)
- [Aim Labs EchoLeak write-up (Cato Networks)](https://www.catonetworks.com/blog/breaking-down-echoleak/)
- [Simon Willison: EchoLeak note (11 June 2025)](https://simonwillison.net/2025/Jun/11/echoleak/)
- [Insinuator: stealing emails via prompt injections (2 September 2025)](https://insinuator.net/2025/09/stealing-emails-via-prompt-injections/)
- [Radware: ShadowLeak (18 September 2025)](https://www.radware.com/blog/threat-intelligence/shadowleak/)
- [HiddenLayer: Gemini for Workspace (25 September 2024)](https://www.hiddenlayer.com/research/new-gemini-for-workspace-vulnerability-enabling-phishing-content-manipulation)
- [0DIN: Phishing For Gemini (10 July 2025)](https://0din.ai/blog/phishing-for-gemini)
- [Google: layered prompt-injection defense (13 June 2025)](https://blog.google/security/mitigating-prompt-injection-attacks/)
- [Google DeepMind, arXiv:2505.14534 (submitted 20 May 2025)](https://arxiv.org/abs/2505.14534)
- [SucuriLabs: how email is used for prompt injection (28 July 2026)](https://sucurilabs.com/blog/how-email-used-prompt-injection)
- [Immersive Labs: weaponizing LLMs through email (21 July 2025)](https://www.immersivelabs.com/resources/c7-blog/weaponizing-llms-bypassing-email-security-products-via-indirect-prompt-injection)
- [Forcepoint X-Labs: HTML payload and an email summarizer (25 August 2026)](https://www.forcepoint.com/blog/x-labs/html-payload-hijacks-email-summarizer)
- [Proofpoint: indirect prompt injection and email (18 November 2025)](https://www.proofpoint.com/us/blog/email-and-cloud-threats/stop-month-how-threat-actors-weaponize-ai-assistants-indirect-prompt)
- [CVE-2024-5184](https://www.cve.org/CVERecord?id=CVE-2024-5184)
- [NVD: CVE-2024-5184](https://nvd.nist.gov/vuln/detail/CVE-2024-5184)
- [Formgong MCP server](https://formgong.com/en/docs/mcp/)

[Get a form key](https://formgong.com/en/#top)
