Wires inline images through the whole compose → send pipeline.
⚠️Migration version correction: the ticket says "Room 12→13", but the schema had advanced — the current version is 17, so this ships MIGRATION_17_18 and exports 18.json.
Compose UI: an image picker (image/*, persistable URI grant, mirroring the attachment picker) behind a new toolbar button appended at the end of the toolbar — after the block/link buttons and the font/size/align controls — so it never shifts the bullet button ComposeScreenTest taps without scrolling (the #73/#76 lesson). Picking an image adds an inline OutgoingAttachment and hands the editor a PendingInlineImage, which RichTextEditing.insertImage drops as a [image: name] token + RichImage(contentId) at the caret. Deleting the token drops the image: onBodyChange reconciles inline attachments against the body's surviving cid: references. Inline images are tracked in ComposeUiState alongside regular attachments but kept out of the attachment-chip row.
Domain / persistence: OutgoingAttachment gains contentId / isInline; the shared draft/outbox attachment JSON carries them (drafts need no migration — an older draft reads back as a plain attachment). The outbox stages files by index as before but now also stores per-file {contentId, isInline} metadata in a new OutboxEntity.attachments column. Room 17 → 18 (MIGRATION_17_18, ALTER TABLE outbox ADD COLUMN attachments TEXT NOT NULL DEFAULT '', following the bccAddresses precedent so fresh-install == migrated; 18.json committed). The send worker pairs each staged file with its metadata by index, with a positional fallback for messages queued before the column existed.
SMTP (SmtpSender): inline images wrap the body in a multipart/related, each with a Content-ID matching the HTML's cid: and inline disposition; regular attachments keep today's multipart/mixed shape.
Reader-side cid: rendering is explicitly out of scope (follow-up), per the ticket.
Test plan
:app:assembleDebug — main + Room schema export (18.json matches the migration DDL byte-for-byte).
:app:testDebugUnitTest:
SmtpSenderTest (GreenMail): an inline image is sent as multipart/related with a Content-ID matching the HTML's cid:; the attachment tests still pass.
GraphSenderTest: an inline image is marked isInline with its contentId; a plain attachment carries neither.
OutgoingAttachmentMapperTest: an inline image's cid↔file pairing round-trips a draft save/reopen; an old draft JSON reads back as a plain attachment.
RichTextEditingTest: insertImage places the token + RichImage, shifts later spans, and round-trips its image through HTML.
:app:lintDebug
:app:ktlintCheck :app:detekt
:app:compileDebugAndroidTestKotlin — MigrationTest.migrate17To18_addsEmptyAttachmentsToOutbox plus the auto-discovered full-chain replay (runs on-device in CI).
Emulator E2E (including the migration tests) left to CI.
## Summary
Wires inline images through the whole compose → send pipeline.
> ⚠️ **Migration version correction:** the ticket says "Room 12→13", but the schema had advanced — the current version is **17**, so this ships **`MIGRATION_17_18`** and exports **`18.json`**.
- **Compose UI**: an image picker (`image/*`, persistable URI grant, mirroring the attachment picker) behind a new toolbar button appended at the **end** of the toolbar — after the block/link buttons and the font/size/align controls — so it never shifts the bullet button `ComposeScreenTest` taps without scrolling (the #73/#76 lesson). Picking an image adds an inline `OutgoingAttachment` and hands the editor a `PendingInlineImage`, which `RichTextEditing.insertImage` drops as a `[image: name]` token + `RichImage(contentId)` at the caret. **Deleting the token drops the image**: `onBodyChange` reconciles inline attachments against the body's surviving `cid:` references. Inline images are tracked in `ComposeUiState` alongside regular attachments but kept out of the attachment-chip row.
- **Domain / persistence**: `OutgoingAttachment` gains `contentId` / `isInline`; the shared draft/outbox attachment JSON carries them (drafts need **no** migration — an older draft reads back as a plain attachment). The outbox stages files by index as before but now also stores per-file `{contentId, isInline}` metadata in a new `OutboxEntity.attachments` column. **Room 17 → 18** (`MIGRATION_17_18`, `ALTER TABLE outbox ADD COLUMN attachments TEXT NOT NULL DEFAULT ''`, following the `bccAddresses` precedent so fresh-install == migrated; `18.json` committed). The send worker pairs each staged file with its metadata by index, with a positional fallback for messages queued before the column existed.
- **SMTP** (`SmtpSender`): inline images wrap the body in a `multipart/related`, each with a `Content-ID` matching the HTML's `cid:` and inline disposition; regular attachments keep today's `multipart/mixed` shape.
- **Graph** (`GraphSender`): inline `fileAttachment`s carry `isInline: true` + `contentId`.
Reader-side `cid:` rendering is explicitly **out of scope** (follow-up), per the ticket.
## Test plan
- [x] `:app:assembleDebug` — main + Room schema export (`18.json` matches the migration DDL byte-for-byte).
- [x] `:app:testDebugUnitTest`:
- `SmtpSenderTest` (GreenMail): an inline image is sent as `multipart/related` with a `Content-ID` matching the HTML's `cid:`; the attachment tests still pass.
- `GraphSenderTest`: an inline image is marked `isInline` with its `contentId`; a plain attachment carries neither.
- `OutgoingAttachmentMapperTest`: an inline image's cid↔file pairing round-trips a draft save/reopen; an old draft JSON reads back as a plain attachment.
- `RichTextEditingTest`: `insertImage` places the token + `RichImage`, shifts later spans, and round-trips its image through HTML.
- [x] `:app:lintDebug`
- [x] `:app:ktlintCheck :app:detekt`
- [x] `:app:compileDebugAndroidTestKotlin` — `MigrationTest.migrate17To18_addsEmptyAttachmentsToOutbox` plus the auto-discovered full-chain replay (runs on-device in CI).
Emulator E2E (including the migration tests) left to CI.
Closes #77
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Wires inline images through the whole compose → send pipeline.
image/*, persistable URI grant, mirroring the attachment picker) behind a new toolbar button appended at the end of the toolbar — after the block/link buttons and the font/size/align controls — so it never shifts the bullet buttonComposeScreenTesttaps without scrolling (the #73/#76 lesson). Picking an image adds an inlineOutgoingAttachmentand hands the editor aPendingInlineImage, whichRichTextEditing.insertImagedrops as a[image: name]token +RichImage(contentId)at the caret. Deleting the token drops the image:onBodyChangereconciles inline attachments against the body's survivingcid:references. Inline images are tracked inComposeUiStatealongside regular attachments but kept out of the attachment-chip row.OutgoingAttachmentgainscontentId/isInline; the shared draft/outbox attachment JSON carries them (drafts need no migration — an older draft reads back as a plain attachment). The outbox stages files by index as before but now also stores per-file{contentId, isInline}metadata in a newOutboxEntity.attachmentscolumn. Room 17 → 18 (MIGRATION_17_18,ALTER TABLE outbox ADD COLUMN attachments TEXT NOT NULL DEFAULT '', following thebccAddressesprecedent so fresh-install == migrated;18.jsoncommitted). The send worker pairs each staged file with its metadata by index, with a positional fallback for messages queued before the column existed.SmtpSender): inline images wrap the body in amultipart/related, each with aContent-IDmatching the HTML'scid:and inline disposition; regular attachments keep today'smultipart/mixedshape.GraphSender): inlinefileAttachments carryisInline: true+contentId.Reader-side
cid:rendering is explicitly out of scope (follow-up), per the ticket.Test plan
:app:assembleDebug— main + Room schema export (18.jsonmatches the migration DDL byte-for-byte).:app:testDebugUnitTest:SmtpSenderTest(GreenMail): an inline image is sent asmultipart/relatedwith aContent-IDmatching the HTML'scid:; the attachment tests still pass.GraphSenderTest: an inline image is markedisInlinewith itscontentId; a plain attachment carries neither.OutgoingAttachmentMapperTest: an inline image's cid↔file pairing round-trips a draft save/reopen; an old draft JSON reads back as a plain attachment.RichTextEditingTest:insertImageplaces the token +RichImage, shifts later spans, and round-trips its image through HTML.:app:lintDebug:app:ktlintCheck :app:detekt:app:compileDebugAndroidTestKotlin—MigrationTest.migrate17To18_addsEmptyAttachmentsToOutboxplus the auto-discovered full-chain replay (runs on-device in CI).Emulator E2E (including the migration tests) left to CI.
Closes #77
🤖 Generated with Claude Code