Skip to content

Optional original-sender header for IMAP inboxes (#348) - #439

Open
amaanJvd wants to merge 2 commits into
abhinavxd:mainfrom
amaanJvd:feat/imap-original-sender-header
Open

Optional original-sender header for IMAP inboxes (#348)#439
amaanJvd wants to merge 2 commits into
abhinavxd:mainfrom
amaanJvd:feat/imap-original-sender-header

Conversation

@amaanJvd

@amaanJvd amaanJvd commented Jul 23, 2026

Copy link
Copy Markdown

Implements #348.

Went with the generic approach you suggested rather than anything Google-specific: there's a new optional original_sender_header field on the IMAP inbox config. When it's set, we take the address from that header (e.g. X-Original-Sender for Google Groups) as the sender, and fall back to the normal From whenever the header isn't there. Left empty it changes nothing, so existing inboxes behave exactly as they do today.

I kept this to the From/sender side only since that's what actually fixes the Google Groups case. If a to remap turns out to be needed we can do it as a small follow-up with its own header field.

A few notes on how it works:

  • The configured header name is threaded into the fetch so it's actually requested from the server, then parsed with net/mail — handles both a bare user@example.com and Name <user@example.com>. Anything that doesn't parse just falls back to From instead of dropping the message.
  • The contact name uses the header's display name when there is one, otherwise the local part of the address.
  • Added the field to the inbox form (both auth variants) with a hint pointing Google Groups users at X-Original-Sender, plus the en-US strings.

For testing I added a unit test around the header parsing (extractSenderFromHeader) covering the display-name, bare-address, missing-header and unparseable cases. go build ./... and go vet ./internal/inbox/... are clean and the email package tests pass. I didn't spin up the frontend locally, but the new field just mirrors the existing IMAP inputs.

Happy to move the field or adjust anything if you'd rather it sit elsewhere.

Summary by CodeRabbit

  • New Features

    • Added an optional IMAP setting to override the effective sender using a configurable original-sender header.
    • Available for both OAuth and manual IMAP inbox setup; when present and valid, emails use the header’s address and display name (otherwise they fall back to the standard sender).
    • Added localized labels, descriptions, and an X-Original-Sender example for Google Groups.
  • Bug Fixes

    • Added validation to reject unsafe/invalid header names.
  • Tests

    • Added unit coverage for header parsing and header-name validation.

Adds an optional per-inbox IMAP setting, `original_sender_header`. When
set, the address in that header (e.g. X-Original-Sender for Google
Groups) is used as the message sender instead of the From header,
falling back to From when the header is absent or unparseable. Empty by
default, so existing inboxes are unaffected.

- models: add OriginalSenderHeader to IMAPConfig (stored in inbox JSON
  config, no migration).
- imap: fetch the configured header, parse it via net/mail, and remap
  the sender address and contact name in processEnvelope.
- frontend: optional field in the email inbox form (both auth modes),
  zod schema, defaults, and en-US strings.
- test: unit coverage for extractSenderFromHeader.

Implements abhinavxd#348 (scoped to From remapping, per maintainer's design).
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: f55013c3-83b7-4ca7-88a5-b99fe2c325ae

📥 Commits

Reviewing files that changed from the base of the PR and between 3a5c061 and f02d36a.

📒 Files selected for processing (4)
  • frontend/apps/main/src/features/admin/inbox/formSchema.js
  • i18n/en-US.json
  • internal/inbox/channel/email/imap.go
  • internal/inbox/channel/email/imap_original_sender_test.go
🚧 Files skipped from review as they are similar to previous changes (3)
  • frontend/apps/main/src/features/admin/inbox/formSchema.js
  • i18n/en-US.json
  • internal/inbox/channel/email/imap.go

📝 Walkthrough

Walkthrough

Adds an optional IMAP original-sender header setting, exposes it in inbox configuration forms, parses the configured header, and uses its sender data during message and contact processing with fallback to the envelope sender.

Changes

IMAP original sender support

Layer / File(s) Summary
Sender header configuration
internal/inbox/models/models.go, frontend/apps/main/src/features/admin/inbox/..., i18n/en-US.json
Adds the optional original_sender_header configuration, validation, localized UI text, and OAuth/manual form controls.
Header fetching and parsing
internal/inbox/channel/email/imap.go, internal/inbox/channel/email/imap_original_sender_test.go
Fetches the configured header, parses address and display name values, and tests supported and invalid header formats.
Sender resolution and contact naming
internal/inbox/channel/email/imap.go
Passes parsed overrides into envelope processing and derives sender and contact names from the override when available.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant IMAPServer
  participant fetchAndProcessMessages
  participant extractSenderFromHeader
  participant processEnvelope
  participant ContactCreation
  IMAPServer->>fetchAndProcessMessages: fetch configured sender header
  fetchAndProcessMessages->>extractSenderFromHeader: parse header value
  extractSenderFromHeader-->>fetchAndProcessMessages: sender address and display name
  fetchAndProcessMessages->>processEnvelope: pass sender override
  processEnvelope->>ContactCreation: create or update contact identity
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: adding an optional original-sender header setting for IMAP inboxes.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@frontend/apps/main/src/features/admin/inbox/formSchema.js`:
- Around line 49-50: Update the original_sender_header schema in the frontend
form validation to trim the value and accept only valid IMAP header field names,
rejecting whitespace-only, colon-containing, control-character, and other
invalid values so invalid input falls back to From. Apply the same validation
and normalization at the backend boundary before constructing the IMAP
HEADER.FIELDS request.

In `@internal/inbox/channel/email/imap.go`:
- Around line 342-344: Update the message metadata construction in the
surrounding IMAP handling flow to populate Meta["from"] from the effective
fromAddress after the override, rather than env.From. Preserve the override
consistently for contact creation, blocking checks, and metadata, while
retaining raw sender data only if an existing separate field supports it.
- Around line 181-185: Update the FETCH command lifecycle in the surrounding
IMAP message-fetch function: ensure fetchCmd.Close() runs on every exit path,
including early returns, and propagate any error it returns instead of returning
success after fetchCmd.Next() reaches normal completion. Preserve the existing
message ingestion behavior while reporting deferred FETCH failures, including
those caused by requesting cfg.OriginalSenderHeader.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c220e66e-c887-4ef9-acff-bd95e2dc223e

📥 Commits

Reviewing files that changed from the base of the PR and between 1a0a78f and 3a5c061.

📒 Files selected for processing (6)
  • frontend/apps/main/src/features/admin/inbox/EmailInboxForm.vue
  • frontend/apps/main/src/features/admin/inbox/formSchema.js
  • i18n/en-US.json
  • internal/inbox/channel/email/imap.go
  • internal/inbox/channel/email/imap_original_sender_test.go
  • internal/inbox/models/models.go

Comment thread frontend/apps/main/src/features/admin/inbox/formSchema.js Outdated
Comment on lines +181 to +185
// Also fetch the configured original-sender header (e.g. X-Original-Sender)
// so the sender can be remapped below.
if cfg.OriginalSenderHeader != "" {
headerFields = append(headerFields, cfg.OriginalSenderHeader)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Tracked files matching imap.go:"
fd -a 'imap.go$' . | sed 's#^\./##'

echo
echo "File outline:"
ast-grep outline internal/inbox/channel/email/imap.go --view compact || true

echo
echo "Relevant source lines 140-220:"
sed -n '140,220p' internal/inbox/channel/email/imap.go | nl -ba -v140

echo
echo "Search for fetchCmd.Close and fetchCmd.Next in repository:"
rg -n "fetchCmd\.(Next|Close)|\.Next\(|\.Close\(\)" internal/inbox/channel/email/imap.go . --glob '!vendor/**' --glob '!node_modules/**' | head -n 120

Repository: abhinavxd/libredesk

Length of output: 484


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Locate go module and go-imap usage:"
rg -n "go-emersion/go-imap|github.com/emersion/go-imap|FetchCommand\." go.mod go.sum internal --glob '!vendor/**' --glob '!node_modules/**' | head -n 80

echo
echo "Module version if present:"
sed -n '/github.com\/emersion\/go-imap/v2/p' go.mod go.sum 2>/dev/null | head -n 20

Repository: abhinavxd/libredesk

Length of output: 648


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import urllib.request
url = "https://raw.githubusercontent.com/emersion/go-imap/v2.0.0-beta.3/imapclient/fetch.go"
try:
    data = urllib.request.urlopen(url, timeout=20).read().decode()
except Exception as e:
    print(f"UNABLE_TO_READ: {e}")
    raise SystemExit(0)
for marker in ["func (c *FetchCommand) Next()", "func (c *FetchCommand) Close()"]:
    idx = data.find(marker)
    print(f"\n--- {marker.split()[1] if 'Next()' in marker else marker} ---")
    if idx != -1:
        text = data[idx:idx+1200]
    else:
        text = "NOT FOUND"
    print(text)
PY

Repository: abhinavxd/libredesk

Length of output: 308


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Relevant source lines 130-230:"
sed -n '130,230p' internal/inbox/channel/email/imap.go | cat -n | sed 's/^/  /;s/ *$/ /;s/^/  /' | awk '{print substr($0,1)'

echo
echo "Search for fetch command handling:"
rg -n "fetchCmd|\.Next\(|\.Close\(\)|FETCH" internal/inbox/channel/email/imap.go internal --glob '!vendor/**' --glob '!node_modules/**' | head -n 200

echo
echo "Read full relevant function if file is not too large:"
wc -l internal/inbox/channel/email/imap.go
sed -n '1,260p' internal/inbox/channel/email/imap.go | cat -n

Repository: abhinavxd/libredesk

Length of output: 301


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Fetch go-imap FetchCommand Close/Next source without TLS validation using HTTP where available,
# or the GitHub raw HTTPS endpoint with env set to skip verification.
python3 - <<'PY'
import urllib.request, urllib.error
url = "https://raw.githubusercontent.com/emersion/go-imamp/v2.0.0-beta.3/imapclient/fetch.go"
for u in [
    "https://raw.githubusercontent.com/emersion/go-imap/v2.0.0-beta.3/imapclient/fetch.go",
    "http://raw.githubusercontent.com/emersion/go-imap/v2.0.0-beta.3/imapclient/fetch.go",
]:
    print(f"Trying {u}")
    try:
        req = urllib.request.Request(u, headers={"User-Agent": "code-rabbit-verifier"})
        ctx = urllib.request.SSLContext()
        ctx.check_hostname = False
        ctx.verify_mode = 0
        data = urllib.request.urlopen(req, context=ctx, timeout=20).read().decode()
        print("OK")
        print("\n--- Candidate definitions ---")
        for term in ["func (c *FetchCommand) Next()", "func (c *FetchCommand) Close()", "func (c *FetchCommand) Error()"]:
            i = data.find(term)
            if i == -1:
                continue
            print(term)
            print(data[i:i+1400])
            print()
        raise SystemExit(0)
    except Exception as e:
        print(f"ERR: {type(e).__name__}: {e}")
PY

Repository: abhinavxd/libredesk

Length of output: 490


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "sed line count then lines 130-240:"
wc -l internal/inbox/channel/email/imap.go
sed -n '130,240p' internal/inbox/channel/email/imap.go

echo
echo "Command handling occurrences:"
rg -n "fetchCmd|Next\(|Close\(|Error|FETCH" internal/inbox/channel/email/imap.go

echo
echo "Fetch command source snippets via httpx/curl/python if available:"
command -v curl || true
command -v wget || true
python3 - <<'PY'
import urllib.request
url = "http://raw.githubusercontent.com/emersion/go-imap/v2.0.0-beta.3/imapclient/fetch.go"
data = urllib.request.urlopen(url, timeout=20).read().decode()
for term in ["func (c *FetchCommand) Next()", "func (c *FetchCommand) Close()", "func (c *FetchCommand) Error()"]:
    i = data.find(term)
    print(f"--- {term} ---")
    print(data[i:i+1500])
PY

Repository: abhinavxd/libredesk

Length of output: 6138


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "First 170 lines of repository file before fetchCmd appears:"
sed -n '1,170p' internal/inbox/channel/email/imap.go

echo
echo "Fetch loop and command result handling:"
sed -n '200,340p' internal/inbox/channel/email/imap.go

echo
echo "Fetch command source snippets with curl:"
url="https://raw.githubusercontent.com/emersion/go-imap/v2.0.0-beta.3/imapclient/fetch.go"
curl -fsSL "$url" | sed -n '/func (c \*FetchCommand) Next()/,/func (c \*FetchCommand) [ACDEHKRSU]/p' | sed '/func (c \*FetchCommand) /{N;N;N;N;N;N;N;N;N;N}'
curl -fsSL "$url" | sed -n '/func (c \*FetchCommand) Close()/,/func (c \*FetchCommand) /p' | sed '/func (c \*FetchCommand) /{N;N;N;N;N;N;N;N;N;N}'
curl -fsSL "$url" | sed -n '/func (c \*FetchCommand) Error()/,/func (c \*FetchCommand) /p' | sed '/func (c \*FetchCommand) /{N;N;N;N;N;N;N;N;N;N}'

Repository: abhinavxd/libredesk

Length of output: 10581


🌐 Web query:

github.com/emersion/go-imap/v2 imapclient FetchCommand Close Error documentation

💡 Result:

In the go-imap/v2 library, the Close method on an imapclient.FetchCommand is used to finalize the command and release associated resources [1][2][3]. If an error occurred during the processing of the FETCH response (such as an IMAP protocol error, network timeout, or issues with a misbehaving server), this error is returned by the Close method [2][4]. Common considerations regarding FetchCommand.Close include: 1. Error Handling: It is standard practice to check the error returned by Close to ensure the command completed successfully [5][2]. Some developers use a deferred function to log or handle these errors, as shown in official examples and community issue reports [5][2]. 2. Blocking Behavior: The Close method may block if the underlying command has not finished reading all data, or it might hang indefinitely if a network timeout occurs or if the server does not send the expected data [1][3]. 3. Resource Management: Calling Close is essential for cleaning up the command's state and internal channels [3][6]. Failure to call it or improper handling can lead to resource leaks or deadlocks [3]. 4. Known Issues: Community reports have identified potential edge cases, such as panics if certain data fields (like FetchItemDataBodySection.Literal) are nil [5] or errors when interacting with servers that produce non-compliant IMAP responses [2][4]. For the most accurate technical implementation details, refer to the official package documentation at https://pkg.go.dev/github.com/emersion/go-imap/v2/imapclient [7][8].

Citations:


🌐 Web query:

github.com/emersion/go-imap/v2 FetchCommands Error Close API

💡 Result:

In the github.com/emersion/go-imap/v2 library, the FetchCommand returned by Client.Fetch provides a streaming API for retrieving message data [1][2]. The Close() method is used to terminate the command and release associated resources [1][2]. Key considerations regarding FetchCommand.Close() and error handling include: 1. Functionality: Close() cleans up the command state and returns an error if the command encountered a failure, such as issues during the parsing of the IMAP response stream [1][3][2]. It is considered good practice to call Close() (often using defer) after you have finished processing results to ensure proper cleanup [1][4][2]. 2. Error Propagation: Because go-imap/v2 is designed for streaming, errors might not appear immediately when calling Next() or Collect() [3]. If the server sends malformed data or violates the IMAP protocol, an error may only surface when the library attempts to fully parse the response stream, which can happen during or after Close() [3]. 3. Known Issues and Stability: - Blocking/Deadlocks: In some earlier versions, Close() could block indefinitely if no results were returned by the fetch operation [5]. - Panics: Edge cases have been reported where Close() might panic if it attempts to access fields (such as FetchItemDataBodySection.Literal) that were not properly initialized by the server's response [6]. When working with go-imap/v2, ensure you handle the error returned by Close() to detect potential issues that occurred during the streaming process [1][3]. If you encounter persistent issues, verify that your implementation follows the latest practices documented in the imapclient package [7].

Citations:


🌐 Web query:

github.com/emersion/go-imap v2 FetchCommand Next returns nil on error

💡 Result:

In github.com/emersion/go-imap/v2, the FetchCommand.Next() method returning nil is the intended behavior when there are no more messages to retrieve from the server for the given FETCH or UID FETCH command [1]. It signifies that the stream of messages for that command has concluded [1]. If you are encountering nil and believe there should be data, or if you are observing unexpected blocking, consider the following technical context: 1. Streaming Nature: Next() is designed to stream messages. When it returns nil, it indicates the channel is empty or the command has finished processing [1]. 2. Command Completion and Blocking: If Next() blocks indefinitely, it is typically because the command has not been properly closed or the server has not sent a completion response [2][3]. Always ensure you call FetchCommand.Close() to release resources and allow the underlying connection to process subsequent commands [3][1]. 3. Server-Side Behavior: Often, Next() returns nil because the IMAP server returned zero messages for the requested sequence or UID set [3]. This can occur if messages were expunged or moved since the sequence numbers were last verified [3]. 4. Error Handling: While Next() returns nil upon command completion, errors (such as connection issues or parsing failures) are generally handled through other mechanisms, such as checking the return value of FetchCommand.Close() or observing the library's logging/error handling patterns during the command lifecycle [3][4][5]. If you suspect a bug (such as an incorrect nil return due to a parsing error or protocol mismatch), verify the server's response logs [5] and check the official repository for known issues related to specific server implementations, as some issues have been reported regarding non-standard server responses [4][5].

Citations:


Propagate FETCH command errors from Close() before returning.

fetchCmd.Next() returns nil for normal completion, while FETCH command errors are reported when the command finishes; this loop has no defer fetchCmd.Close() and returns nil immediately after the stream ends, so a failed FETCH due to the configured header request can be treated as success with no messages ingested. Close the command on all exit paths and propagate any returned error.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/inbox/channel/email/imap.go` around lines 181 - 185, Update the
FETCH command lifecycle in the surrounding IMAP message-fetch function: ensure
fetchCmd.Close() runs on every exit path, including early returns, and propagate
any error it returns instead of returning success after fetchCmd.Next() reaches
normal completion. Preserve the existing message ingestion behavior while
reporting deferred FETCH failures, including those caused by requesting
cfg.OriginalSenderHeader.

Source: MCP tools

Comment thread internal/inbox/channel/email/imap.go
- Record the remapped address in message meta "from" so it stays
  consistent with the contact (and isn't left empty when the envelope
  has no From).
- Trim and validate the configured header name before using it in the
  IMAP HEADER.FIELDS request; an invalid value is ignored (falls back to
  From) instead of risking a malformed fetch. Mirror the trim/validation
  in the frontend schema.

Addresses review feedback on abhinavxd#439.
@amaanJvd

Copy link
Copy Markdown
Author

Thanks for the review — pushed a follow-up for two of the three:

  • Sender in message meta: good catch, that was inconsistent. When the sender is remapped, meta["from"] now uses the effective address so it matches the contact (and isn't left empty when the envelope has no From).
  • Header name validation: the configured header is now trimmed and checked (printable ASCII, no spaces/colon) before it goes into HEADER.FIELDS; anything invalid is ignored and we fall back to From rather than risk a malformed fetch. Added the same trim/validation to the frontend schema too.

On the FETCH Close() error propagation — I left that one out on purpose since it's pre-existing in fetchAndProcessMessages (the loop already returns nil after the stream ends regardless of my change), and validating the header removes the path where this feature could trigger a failed fetch. Happy to fix it here if you'd like, but figured it's a separate robustness change worth its own PR rather than quietly folding it into this one.

go build ./..., go vet, and the email package tests are still green.

@amaanJvd

Copy link
Copy Markdown
Author

Thanks for merging #454 🙏

Whenever you get a chance, this one (the optional original-sender header) is ready too — I pushed fixes for the CodeRabbit feedback a while back (effective sender now consistent in message meta, plus header-name trim/validation), and go build / go vet / the email package tests are all green. Happy to reshape anything if you'd prefer it done differently.

@abhinavxd

Copy link
Copy Markdown
Owner

Hey,

I'll check this over weekend.

@abhinavxd

abhinavxd commented Aug 16, 2026

Copy link
Copy Markdown
Owner

I'll merge this closer to next release, as HelpCenter is releasing next.
This is a risky change and needs to be carefully checked.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants