Skip to content

Embed an image in the body, not just beside it - #8

Merged
rutgerhofste merged 1 commit into
mainfrom
claude/microsoft-agent-attachments-3wijn2
Aug 7, 2026
Merged

Embed an image in the body, not just beside it#8
rutgerhofste merged 1 commit into
mainfrom
claude/microsoft-agent-attachments-3wijn2

Conversation

@rutgerhofste

@rutgerhofste rutgerhofste commented Aug 7, 2026

Copy link
Copy Markdown
Member

Rebased onto #7. This branch originally defined its own attachments seam, because the Graph provider in the admin package needed one to exist. #7 merged first with a better one — source_uid forwarding that never routes bytes through the model, supports_outgoing_attachments so a silent backend cannot swallow a file, a size limit read from SMTP's own SIZE extension. All of that is kept; my duplicate is gone. What remains is the one thing #7 deliberately left out.

From OutgoingAttachment's docstring as merged:

There is no content_id and no inline flag. Inline only means anything against an HTML body that references the part with cid:, and the tool layer composes plain text — a multipart/related holding an image nothing points at renders differently in every client, which is worse than an honest attachment. Inline sending waits for an HTML compose path.

This is that path. The caution is not dropped, it is enforced.

What it adds

body_html, as an alternativebody stays the text a plain-text client shows, and both halves are the same message. And inline / content_id on OutgoingAttachment, where inline is only placed inline when there is HTML that could carry the reference, and degrades to an ordinary attachment when there is not, keeping its Content-ID for a client that wants it anyway.

The placement is the feature

mime._add_parts. An inline part goes inside multipart/related next to the HTML, because a client resolves cid: within that group and nowhere else — the same image parked in the outer multipart/mixed beside the paperclips renders in some clients and arrives as a second paperclip in the rest.

case structure
no html, no files text/plain — unchanged
html only alternative[text, html]
html + inline alternative[text, related[html, image]]
html + inline + file mixed[alternative[text, related[html, image]], file]
inline, no html mixed[text, image] — degraded, honestly

Every inline part ends up with a content_id whether the caller gave one or not, since a part nothing can name is a part nothing can show, and the three spellings a model reaches for (logo, cid:logo, <logo>) reduce to one. source_uid and inline are orthogonal, so a signature image already in the mailbox can be re-embedded without routing the bytes through the model.

Testing

  • tests/test_attachments_inline.py (new, 17 tests): the trees above, the content-id handling, and the tool seam. Only what inline adds — test_attachments_outgoing.py still owns the argument forms and the refusal rules.
  • GreenMail e2e (1 new): reads the delivered multipart/related back off a real server, Content-ID and both bodies included. Not run locally (no Docker daemon here); the integration-greenmail CI job covers it.
  • make test 155 passed (Send attachments over SMTP, not just read them #7's suite included), make lint clean.

Sibling: pantalytics/squirrel-mcp-admin#58 does the same for Outlook over Graph, and needs this branch for body_html.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ssg4B9jnGCrYKNK9DbqKbv

Outgoing attachments landed in #7 with inline deliberately left out, for a
reason its own docstring spelled out: inline means nothing without an HTML body
referencing the part, and a multipart/related holding an image nothing points
at renders differently in every client. This is the HTML compose path that was
waiting on, and the reason is now enforced rather than avoided.

body_html is added as an *alternative*, never a replacement -- body stays the
text a plain-text client shows, and both halves are the same message. An
attachment marked inline is only *placed* inline when there is HTML that could
carry the cid: reference, and degrades to an ordinary attachment when there is
not, keeping its Content-ID for a client that wants to show it anyway.

The placement is the whole feature and no unit above the MIME layer can see it.
An inline part goes inside multipart/related next to the HTML, because a client
resolves cid: within that group and nowhere else; the same image parked in the
outer multipart/mixed beside the paperclips renders in some clients and arrives
as a second paperclip in the rest. So the shape is
mixed[alternative[text, related[html, image]], file] -- and a message with
neither HTML nor attachments is still the single text/plain part it always was.

Every inline part ends up with a content_id whether the caller gave one or not,
since a part nothing can name is a part nothing can show, and the three
spellings a model reaches for -- logo, cid:logo, <logo> -- reduce to one.
source_uid and inline are orthogonal: a signature image already in the mailbox
can be re-embedded without routing the bytes through the model.

tests/test_attachments_inline.py pins the trees and the id handling; the
GreenMail e2e reads the delivered multipart/related back off a real server,
Content-ID and both bodies included.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ssg4B9jnGCrYKNK9DbqKbv
@rutgerhofste
rutgerhofste force-pushed the claude/microsoft-agent-attachments-3wijn2 branch from 54c44ba to 1048c2d Compare August 7, 2026 13:19
@rutgerhofste rutgerhofste changed the title Send files, inline or attached, one or many Embed an image in the body, not just beside it Aug 7, 2026
@rutgerhofste
rutgerhofste marked this pull request as ready for review August 7, 2026 13:25
@rutgerhofste
rutgerhofste merged commit 06da424 into main Aug 7, 2026
3 checks passed
@rutgerhofste
rutgerhofste deleted the claude/microsoft-agent-attachments-3wijn2 branch August 13, 2026 14:29
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.

2 participants