Repository: ahochsteger/gmail-processor

Issue:
message match by header
**Is your feature request related to a problem? Please describe.**
My company uses Google group email as a shared inbox. When receiving email to a group email. the message From header is rewritten to a nonsensical, generic value containing the email address of the receiver instead of the sender. For example, if the email is sent from test@test.com to group@mycompany.com, the message header From: field contains "Test user via group@mycompany.com" instead of test@test.com.

Further explanation of this "feature" is here:
https://wiki.ietf.org/group/dmarc/XOriginalFrom

We use the message.from.domain placeholder for storing attachments, which - for Google Group inbox - translates to our own domain name.

**Describe the solution you'd like**
It would help to extend the message matching feature to filtering by message header as well. In this case, I would be able to use the X-Original-From header instead of the From address and with fill the proper placeholder value with use of Regexp.

**Describe alternatives you've considered**
I have tried a regex match with body and plainBody fields, but they do not contain message headers.

Thank you for your great work.

Hints from issue comments:
@ahochsteger: @kulhos the latest version [v2.15.0](https://github.com/ahochsteger/gmail-processor/releases/tag/v2.15.0) now supports matching raw message headers.
Have a look at the [Header Matching](https://ahochsteger.github.io/gmail-processor/docs/examples/advanced/headerMatching) example to see how the `rawHeader` field is used to match message headers.
I hope this solves your use case.@kulhos: Great, works like a charm! Thank you very much!

Solution patch:
diff --git a/docs/docs/examples/advanced/headerMatching.mdx b/docs/docs/examples/advanced/headerMatching.mdx
new file mode 100644
index 0000000..01366d1
--- /dev/null
+++ b/docs/docs/examples/advanced/headerMatching.mdx
@@ -0,0 +1,20 @@
+---
+id: headerMatching
+title: Header Matching
+description: This example demonstrates how to match raw message headers.
+---
+import RenderExample from "/src/components/RenderExample.tsx"
+import config from '!!raw-loader!../../../../src/examples/advanced/headerMatching.json';
+import script from '!!raw-loader!../../../../src/gas/examples/advanced/headerMatching.js';
+import { info } from "../../../../src/examples/advanced/headerMatching.ts"
+
+This example demonstrates how to use the `rawHeaders` property in the message matching configuration.
+It allows matching messages based on the content of their raw headers using regular expressions.
+
+In this specific example:
+1.  It first matches threads sent by the current user (`from:${user.email}`).
+2.  Within those threads, it matches messages where the raw headers contain a `From:` line exactly matching the user's email address (`(?m)^From: ${user.email}$`). Note the use of `(?m)` at the beginning of the regex to allow matching `^` and `$` at start/end of line (multi-line matching).
+3.  For matching messages, it processes attachments named `invoice.pdf`.
+4.  Matching attachments are stored in Google Drive using a dynamically generated path based on message and attachment details.
+
+<RenderExample info={info} config={config} script={script} />
diff --git a/docs/docs/reference/placeholder.mdx b/docs/docs/reference/placeholder.mdx
index b819c30..8853dfa 100644
--- a/docs/docs/reference/placeholder.mdx
+++ b/docs/docs/reference/placeholder.mdx
@@ -83,6 +83,7 @@ These placeholder are valid during processing a GMail message and matching attac
 | <a id="placeholder.message.isUnread">`message.isUnread`</a> | Unread Message | `true` if the message is unread. See [GmailMessage.isUnread()](https://developers.google.com/apps-script/reference/gmail/gmail-message#isUnread\(\)) reference docs. | `"true"` |
 | <a id="placeholder.message.matched">`message.matched`</a> | message Regex Matches | The overall matching result for all conditions in the match config. | `"true"` |
 | <a id="placeholder.message.plainBody">`message.plainBody`</a> | Message Plain Body | The plain body of the message. See [GmailMessage.getPlainBody()](https://developers.google.com/apps-script/reference/gmail/gmail-message#getPlainBody\(\)) reference docs. | `""` |
+| <a id="placeholder.message.rawHeaders">`message.rawHeaders`</a> | Message Raw Headers | The raw headers of the message. See [GmailMessage.getRawContent()](https://developers.google.com/apps-script/reference/gmail/gmail-message#getRawContent\(\)) reference docs. | `"Date: Thu, 02 May 2019 07:15:28...` |
 | <a id="placeholder.message.replyTo">`message.replyTo`</a> | Message Reply To | The reply-to address of the message (usually the sender). See [GmailMessage.getReplyTo()](https://developers.google.com/apps-script/reference/gmail/gmail-message#getReplyTo\(\)) reference docs. | `"message-reply-to@example.com"` |
 | <a id="placeholder.message.subject">`message.subject`</a> | Message Subject | The subject of the message. See [GmailMessage.getSubject()](https://developers.google.com/apps-script/reference/gmail/gmail-message#getSubject\(\)) reference docs. | `"Message Subject 1"` |
 | <a id="placeholder.message.subject.match.1">`message.subject.match.1`</a> | message.subject Regex Match Group 1 | The matching regex group number as defined in the match config (e.g.: `"Message (?<myMatchGroup>.*)"`). | `"Subject 1"` |
diff --git a/src/examples/advanced/headerMatching.json b/src/examples/advanced/headerMatching.json
new file mode 100644
index 0000000..4979f64
--- /dev/null
+++ b/src/examples/advanced/headerMatching.json
@@ -0,0 +1,43 @@
+{
+  "description": "This example demonstrates how to match raw message headers.",
+  "settings": {
+    "markProcessedMethod": "mark-read"
+  },
+  "global": {
+    "thread": {
+      "match": {
+        "query": "has:attachment -in:trash -in:drafts -in:spam after:{{date.now|formatDate('yyyy-MM-dd')}} is:unread subject:\"[GmailProcessor-Test] headerMatching\""
+      }
+    }
+  },
+  "threads": [
+    {
+      "match": {
+        "query": "from:${user.email}"
+      },
+      "messages": [
+        {
+          "match": {
+            "rawHeaders": "(?m)^From: ${user.email}$"
+          },
+          "attachments": [
+            {
+              "match": {
+                "name": "^invoice\\.pdf$"
+              },
+              "actions": [
+                {
+                  "name": "attachment.store",
+                  "args": {
+                    "location": "/GmailProcessor-Tests/e2e/headerMatching/{{message.date|formatDate('yyyy-MM-dd')}}/{{message.subject}}-{{attachment.name}}",
+                    "conflictStrategy": "keep"
+                  }
+                }
+              ]
+            }
+          ]
+        }
+      ]
+    }
+  ]
+}
diff --git a/src/examples/advanced/headerMatching.spec.ts b/src/examples/advanced/headerMatching.spec.ts
new file mode 100644
index 0000000..439ed29
--- /dev/null
+++ b/src/examples/advanced/headerMatching.spec.ts
@@ -0,0 +1,27 @@
+// NOTE: Do not edit this auto-generated file!
+// Template: src/templates/test-spec.eta
+// Source: src/examples/advanced/headerMatching.ts
+
+import { ProcessingStatus } from "../../lib"
+import { GmailProcessor } from "../../lib/processors/GmailProcessor"
+import { MockFactory, Mocks } from "../../test/mocks/MockFactory"
+import { validateConfig } from "./../../lib/config/Validate"
+import { info, runConfig } from "./headerMatching"
+
+let mocks: Mocks
+
+beforeAll(() => {
+  mocks = MockFactory.newMocks()
+})
+
+describe(`Example ${info.name}`, () => {
+  it(`should successfully run example ${info.name}`, () => {
+    const result = GmailProcessor.runWithJson(runConfig, [], mocks.envContext)
+    expect(result.status).toEqual(ProcessingStatus.OK)
+    expect(result.processedThreadConfigs).toEqual(runConfig.threads?.length)
+  })
+  it(`should successfully validate example ${info.name}`, () => {
+    validateConfig(runConfig)
+    expect(validateConfig.errors).toBeNull()
+  })
+})
diff --git a/src/examples/advanced/headerMatching.ts b/src/examples/advanced/headerMatching.ts
new file mode 100644
index 0000000..fb3fd70
--- /dev/null
+++ b/src/examples/advanced/headerMatching.ts
@@ -0,0 +1,117 @@
+import { ProcessingStatus } from "../../lib/Context"
+import { ConflictStrategy } from "../../lib/adapter/GDriveAdapter"
+import { Config } from "../../lib/config/Config"
+import { MarkProcessedMethod } from "../../lib/config/SettingsConfig"
+import { E2EInitConfig, E2ETest, E2ETestConfig } from "../../lib/e2e/E2E"
+import { Example, ExampleCategory, ExampleInfo } from "../Example"
+import { E2EDefaults } from "./../../lib/e2e/E2EDefaults"
+
+/** This example demonstrates how to use the `rawHeaders` property in the message matching configuration.
+ * It allows matching messages based on the content of their raw headers using regular expressions.
+ *
+ * In this specific example:
+ * 1.  It first matches threads sent by the current user (`from:${user.email}`).
+ * 2.  Within those threads, it matches messages where the raw headers contain a `From:` line exactly matching the user's email address (`(?m)^From: ${user.email}$`). Note the use of `(?m)` at the beginning of the regex to allow matching `^` and `$` at start/end of line (multi-line matching).
+ * 3.  For matching messages, it processes attachments named `invoice.pdf`.
+ * 4.  Matching attachments are stored in Google Drive using a dynamically generated path based on message and attachment details.
+ */
+export const info: ExampleInfo = {
+  name: "headerMatching",
+  title: "Header Matching",
+  description: "This example demonstrates how to match raw message headers.",
+  category: ExampleCategory.ADVANCED,
+}
+
+export const initConfig: E2EInitConfig = {
+  mails: [
+    {
+      attachments: ["invoice.pdf"],
+    },
+  ],
+}
+
+export const runConfig: Config = {
+  description: info.description,
+  settings: {
+    // ATTENTION: Decide on the method to be used to mark processed threads/messages:
+    markProcessedMethod: MarkProcessedMethod.MARK_MESSAGE_READ,
+    // Add more settings if required ...
+  },
+  global: {
+    // Place global thread, message or attachment configuration here
+    thread: {
+      match: {
+        query: `has:attachment -in:trash -in:drafts -in:spam after:{{date.now|formatDate('yyyy-MM-dd')}} is:unread subject:"${E2EDefaults.EMAIL_SUBJECT_PREFIX}${info.name}"`,
+      },
+    },
+  },
+  threads: [
+    // Place thread processing config here
+    {
+      match: {
+        query: "from:${user.email}",
+      },
+      messages: [
+        // Place message processing config here
+        {
+          match: {
+            rawHeaders: "(?m)^From: ${user.email}$",
+          },
+          attachments: [
+            // Place attachment processing config here
+            {
+              match: {
+                name: "^invoice\\.pdf$",
+              },
+              actions: [
+                {
+                  name: "attachment.store",
+                  args: {
+                    location: `${E2EDefaults.DRIVE_TESTS_BASE_PATH}/${info.name}/{{message.date|formatDate('yyyy-MM-dd')}}/{{message.subject}}-{{attachment.name}}`,
+                    conflictStrategy: ConflictStrategy.KEEP,
+                  },
+                },
+              ],
+            },
+          ],
+        },
+      ],
+    },
+  ],
+}
+
+export const example: Example = {
+  info,
+  config: runConfig,
+}
+
+export const tests: E2ETest[] = [
+  {
+    message: "No failures",
+    assertions: [
+      {
+        message: "Processing status should be OK",
+        assertFn: (_testConfig, procResult) =>
+          procResult.status === ProcessingStatus.OK,
+      },
+      {
+        message: "At least one thread should have been processed",
+        assertFn: (_testConfig, procResult) => procResult.processedThreads >= 1,
+      },
+      {
+        message: "Expected number of actions should have been executed",
+        assertFn: (_testConfig, procResult) =>
+          procResult.executedActions.length ===
+          procResult.processedThreads + procResult.processedAttachments,
+      },
+    ],
+  },
+]
+
+export const testConfig: E2ETestConfig = {
+  example,
+  info,
+  initConfig,
+  runConfig,
+  tests,
+}
diff --git a/src/examples/index.ts b/src/examples/index.ts
index dcadf8a..12e6d80 100644
--- a/src/examples/index.ts
+++ b/src/examples/index.ts
@@ -6,6 +6,7 @@ import { example as actionThreadRemoveLabelExample } from "./actions/actionThrea
 import { example as customActionsExample } from "./advanced/customActions"
 import { example as dateExpressionsExample } from "./advanced/dateExpressions"
 import { example as decryptPdfExample } from "./advanced/decryptPdf"
+import { example as headerMatchingExample } from "./advanced/headerMatching"
 import { example as logSheetLoggingExample } from "./advanced/logSheetLogging"
 import { example as regularExpressionsExample } from "./advanced/regularExpressions"
 import { example as simpleExample } from "./basics/simple"
@@ -24,6 +25,7 @@ export const allExamples: (Example | V1Example)[] = [
   customActionsExample,
   dateExpressionsExample,
   decryptPdfExample,
+  headerMatchingExample,
   issue301Example,
   legacyExpressionsExample,
   logSheetLoggingExample,
diff --git a/src/gas/examples/advanced/headerMatching-test.js b/src/gas/examples/advanced/headerMatching-test.js
new file mode 100644
index 0000000..3055316
--- /dev/null
+++ b/src/gas/examples/advanced/headerMatching-test.js
@@ -0,0 +1,128 @@
+// NOTE: Do not edit this auto-generated file!
+// Template: src/templates/gas-test.eta
+// Source: src/examples/advanced/headerMatching.ts
+
+function headerMatchingTestConfig() {
+  /** This example demonstrates how to use the `rawHeaders` property in the message matching configuration.
+   * It allows matching messages based on the content of their raw headers using regular expressions.
+   *
+   * In this specific example:
+   * 1.  It first matches threads sent by the current user (`from:${user.email}`).
+   * 2.  Within those threads, it matches messages where the raw headers contain a `From:` line exactly matching the user's email address (`(?m)^From: ${user.email}$`). Note the use of `(?m)` at the beginning of the regex to allow matching `^` and `$` at start/end of line (multi-line matching).
+   * 3.  For matching messages, it processes attachments named `invoice.pdf`.
+   * 4.  Matching attachments are stored in Google Drive using a dynamically generated path based on message and attachment details.
+   */
+  const info = {
+    name: "headerMatching",
+    title: "Header Matching",
+    description: "This example demonstrates how to match raw message headers.",
+    category: "advanced",
+  }
+
+  const initConfig = {
+    mails: [
+      {
+        attachments: ["invoice.pdf"],
+      },
+    ],
+  }
+
+  const runConfig = {
+    description: info.description,
+    settings: {
+      // ATTENTION: Decide on the method to be used to mark processed threads/messages:
+      markProcessedMethod:
+        GmailProcessorLib.MarkProcessedMethod.MARK_MESSAGE_READ,
+      // Add more settings if required ...
+    },
+    global: {
+      // Place global thread, message or attachment configuration here
+      thread: {
+        match: {
+          query: `has:attachment -in:trash -in:drafts -in:spam after:{{date.now|formatDate('yyyy-MM-dd')}} is:unread subject:"${GmailProcessorLib.E2EDefaults.EMAIL_SUBJECT_PREFIX}${info.name}"`,
+        },
+      },
+    },
+    threads: [
+      // Place thread processing config here
+      {
+        match: {
+          query: "from:${user.email}",
+        },
+        messages: [
+          // Place message processing config here
+          {
+            match: {
+              rawHeaders: "(?m)^From: ${user.email}$",
+            },
+            attachments: [
+              // Place attachment processing config here
+              {
+                match: {
+                  name: "^invoice\\.pdf$",
+                },
+                actions: [
+                  {
+                    name: "attachment.store",
+                    args: {
+                      location: `${GmailProcessorLib.E2EDefaults.DRIVE_TESTS_BASE_PATH}/${info.name}/{{message.date|formatDate('yyyy-MM-dd')}}/{{message.subject}}-{{attachment.name}}`,
+                      conflictStrategy: GmailProcessorLib.ConflictStrategy.KEEP,
+                    },
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  }
+
+  const example = {
+    info,
+    config: runConfig,
+  }
+
+  const tests = [
+    {
+      message: "No failures",
+      assertions: [
+        {
+          message: "Processing status should be OK",
+          assertFn: (_testConfig, procResult) =>
+            procResult.status === GmailProcessorLib.ProcessingStatus.OK,
+        },
+        {
+          message: "At least one thread should have been processed",
+          assertFn: (_testConfig, procResult) =>
+            procResult.processedThreads >= 1,
+        },
+        {
+          message: "Expected number of actions should have been executed",
+          assertFn: (_testConfig, procResult) =>
+            procResult.executedActions.length ===
+            procResult.processedThreads + procResult.processedAttachments,
+        },
+      ],
+    },
+  ]
+
+  const testConfig = {
+    example,
+    info,
+    initConfig,
+    runConfig,
+    tests,
+  }
+  return testConfig
+}
+
+function headerMatchingTest() {
+  const testConfig = headerMatchingTestConfig()
+  return GmailProcessorLib.E2E.runTests(
+    testConfig,
+    false,
+    E2E_REPO_BRANCH,
+    GmailProcessorLib.RunMode.DANGEROUS,
+  )
+}
diff --git a/src/gas/examples/advanced/headerMatching.js b/src/gas/examples/advanced/headerMatching.js
new file mode 100644
index 0000000..346d0d1
--- /dev/null
+++ b/src/gas/examples/advanced/headerMatching.js
@@ -0,0 +1,49 @@
+function headerMatchingRun() {
+  const config = {
+    description: "This example demonstrates how to match raw message headers.",
+    settings: {
+      markProcessedMethod: "mark-read",
+    },
+    global: {
+      thread: {
+        match: {
+          query:
+            "has:attachment -in:trash -in:drafts -in:spam after:{{date.now|formatDate('yyyy-MM-dd')}} is:unread subject:\"[GmailProcessor-Test] headerMatching\"",
+        },
+      },
+    },
+    threads: [
+      {
+        match: {
+          query: "from:${user.email}",
+        },
+        messages: [
+          {
+            match: {
+              rawHeaders: "(?m)^From: ${user.email}$",
+            },
+            attachments: [
+              {
+                match: {
+                  name: "^invoice\\.pdf$",
+                },
+                actions: [
+                  {
+                    name: "attachment.store",
+                    args: {
+                      location:
+                        "/GmailProcessor-Tests/e2e/headerMatching/{{message.date|formatDate('yyyy-MM-dd')}}/{{message.subject}}-{{attachment.name}}",
+                      conflictStrategy: "keep",
+                    },
+                  },
+                ],
+              },
+            ],
+          },
+        ],
+      },
+    ],
+  }
+
+  return GmailProcessorLib.run(config, "dry-run")
+}
diff --git a/src/gas/examples/all-tests.js b/src/gas/examples/all-tests.js
index d2d75dc..436596a 100644
--- a/src/gas/examples/all-tests.js
+++ b/src/gas/examples/all-tests.js
@@ -10,6 +10,7 @@ function getTestConfigs() {
     customActionsTestConfig(),
     dateExpressionsTestConfig(),
     decryptPdfTestConfig(),
+    headerMatchingTestConfig(),
     issue301TestConfig(),
     legacyExpressionsTestConfig(),
     logSheetLoggingTestConfig(),
diff --git a/src/lib/config/MessageMatchConfig.ts b/src/lib/config/MessageMatchConfig.ts
index b6dbd72..994a2cc 100644
--- a/src/lib/config/MessageMatchConfig.ts
+++ b/src/lib/config/MessageMatchConfig.ts
@@ -33,6 +33,11 @@ export class MessageMatchConfig {
    * Use `(?s)` at the beginning of the regex if you want `.` to match a newline.
    */
   plainBody? = ".*"
+  /**
+   * A RegEx matching the raw headers of messages.
+   * Use `(?s)` at the beginning of the regex if you want `.` to match a newline.
+   */
+  rawHeaders? = ".*"
   /**
    * A RegEx matching the subject of messages
    */
diff --git a/src/lib/config/config-schema-v2.json b/src/lib/config/config-schema-v2.json
index 9f4e52c..07f014a 100644
--- a/src/lib/config/config-schema-v2.json
+++ b/src/lib/config/config-schema-v2.json
@@ -2774,6 +2774,12 @@
                     "title": "plainBody",
                     "type": "string"
                 },
+                "rawHeaders": {
+                    "default": ".*",
+                    "description": "A RegEx matching the raw headers of messages.\nUse `(?s)` at the beginning of the regex if you want `.` to match a newline.",
+                    "title": "rawHeaders",
+                    "type": "string"
+                },
                 "subject": {
                     "default": ".*",
                     "description": "A RegEx matching the subject of messages",
diff --git a/src/lib/processors/MessageProcessor.spec.ts b/src/lib/processors/MessageProcessor.spec.ts
index c48c3a7..db537f9 100644
--- a/src/lib/processors/MessageProcessor.spec.ts
+++ b/src/lib/processors/MessageProcessor.spec.ts
@@ -92,6 +92,24 @@ describe("match()", () => {
           },
           expected: ["message-1"],
         },
+        {
+          config: {
+            // Match the From header of the first message
+            rawHeaders: "(?m)^From: from1@example.com\r\n",
+          },
+          expected: ["message-1"],
+        },
+        {
+          config: {
+            // Match the Subject header of the second message using a broader regex
+            rawHeaders: "(?s)Subject: message-2.*Message-ID:",
+          },
+          expected: ["message-2"],
+        },
+        {
+          config: { rawHeaders: "(?m)^X-NonExistent-Header: .*" },
+          expected: [],
+        },
       ]
     const mockedThread = GMailMocks.newThreadMock({
       messages: [
diff --git a/src/lib/processors/MessageProcessor.ts b/src/lib/processors/MessageProcessor.ts
index 640d895..3a95ee2 100644
--- a/src/lib/processors/MessageProcessor.ts
+++ b/src/lib/processors/MessageProcessor.ts
@@ -41,6 +41,17 @@ export class MessageProcessor extends BaseProcessor {
     return messageContext
   }
 
+  private static extractHeaders(rawContent?: string): string {
+    if (!rawContent) {
+      return ""
+    }
+    // Split by the first occurrence of a double newline (CRLF or LF)
+    // The limit parameter '1' ensures we only split into headers and the rest
+    const parts = rawContent.split(/\r?\n\r?\n/, 1)
+    // The first part (parts[0]) will always be the headers, even if no separator is found
+    return parts[0]
+  }
+
   public static matches(
     ctx: ThreadContext,
     matchConfig: RequiredMessageMatchConfig,
@@ -65,6 +76,12 @@ export class MessageProcessor extends BaseProcessor {
         value: message.getPlainBody(),
         config: matchConfig.plainBody,
       },
+      {
+        name: "rawHeaders",
+        type: "regex",
+        value: this.extractHeaders(message.getRawContent()),
+        config: matchConfig.rawHeaders,
+      },
       {
         name: "to",
         type: "regex",
@@ -128,6 +145,10 @@ export class MessageProcessor extends BaseProcessor {
         ctx,
         this.effectiveValue(global.plainBody, local.plainBody, ""),
       ),
+      rawHeaders: PatternUtil.substitute(
+        ctx,
+        this.effectiveValue(global.rawHeaders, local.rawHeaders, ""),
+      ),
       subject: PatternUtil.substitute(
         ctx,
         this.effectiveValue(global.subject, local.subject, ""),
@@ -150,6 +171,7 @@ export class MessageProcessor extends BaseProcessor {
     if (mmc.body) m.set("body", mmc.body)
     if (mmc.from) m.set("from", mmc.from)
     if (mmc.plainBody) m.set("plainBody", mmc.plainBody)
+    if (mmc.rawHeaders) m.set("rawHeaders", mmc.rawHeaders)
     if (mmc.subject) m.set("subject", mmc.subject)
     if (mmc.to) m.set("to", mmc.to)
     return m
@@ -296,6 +318,16 @@ export class MessageProcessor extends BaseProcessor {
           "The plain body of the message.",
         ),
       ),
+      [`${keyPrefix}.rawHeaders`]: mi(
+        MIT.STRING,
+        (msg: Message) => this.extractHeaders(msg.getRawContent()),
+        "Message Raw Headers",
+        this.getRefDocs(
+          keyPrefix,
+          "getRawContent",
+          "The raw headers of the message.",
+        ),
+      ),
       [`${keyPrefix}.replyTo`]: mi(
         MIT.STRING,
         (msg: Message) => msg.getReplyTo(),



Test patch:
diff --git a/src/test/mocks/GMailMocks.ts b/src/test/mocks/GMailMocks.ts
index f55dbac..b14cdcd 100644
--- a/src/test/mocks/GMailMocks.ts
+++ b/src/test/mocks/GMailMocks.ts
@@ -136,6 +136,21 @@ export class GMailMocks {
     // NOTE: Keep MessageData, newMessageMock and getMessageSampleData in sync
     const d = this.getMessageSampleData(data)
     d.attachments = d.attachments ? d.attachments : []
+    const buildRawContent = (msgData: RequiredMessageData): string => {
+      let headers = `Date: ${msgData.date.toUTCString()}\r\n`
+      headers += `From: ${msgData.from}\r\n`
+      headers += `To: ${msgData.to}\r\n`
+      if (msgData.cc && msgData.cc.length > 0)
+        headers += `Cc: ${msgData.cc}\r\n`
+      if (msgData.bcc && msgData.bcc.length > 0)
+        headers += `Bcc: ${msgData.bcc}\r\n` // Note: BCC usually not in raw headers, but included for testing flexibility
+      headers += `Subject: ${msgData.subject}\r\n`
+      headers += `Message-ID: <${msgData.id}@mail.gmail.com>\r\n` // Example Message-ID format
+      if (msgData.replyTo && msgData.replyTo.length > 0)
+        headers += `Reply-To: ${msgData.replyTo}\r\n`
+      headers += `Content-Type: text/html; charset=utf-8\r\n` // Assuming HTML body for simplicity
+      return `${headers}\r\n${msgData.body}`
+    }
     const m = mock<GoogleAppsScript.Gmail.GmailMessage>()
     m.forward.mockReturnValue(m)
     m.getAttachments.mockReturnValue(this.getAttachments(d))
@@ -146,6 +161,7 @@ export class GMailMocks {
     m.getFrom.mockReturnValue(d.from)
     m.getId.mockReturnValue(d.id)
     m.getReplyTo.mockReturnValue(d.replyTo)
+    m.getRawContent.mockReturnValue(buildRawContent(d))
     m.getSubject.mockReturnValue(d.subject)
     m.getTo.mockReturnValue(d.to)
     m.isDraft.mockReturnValue(d.isDraft)