Wire inline images through the whole send pipeline.
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 the compose E2E taps without scrolling. 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, DEFAULT '' per
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 fileAttachments carry isInline + contentId.
Tests: GreenMail asserts multipart/related with a Content-ID matching the cid;
GraphSenderTest asserts the inline payload; a mapper test proves an inline
image's cid<->file pairing round-trips a draft save/reopen; RichTextEditing
tests cover insertImage (token/RichImage placement + offset shift + html
round-trip); MigrationTest gains migrate17To18. Reader-side cid: rendering stays
out of scope (follow-up).
Closes #77
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
467 lines
14 KiB
JSON
467 lines
14 KiB
JSON
{
|
|
"formatVersion": 1,
|
|
"database": {
|
|
"version": 18,
|
|
"identityHash": "f4b80b1d988222a691f7aeea4d45e37e",
|
|
"entities": [
|
|
{
|
|
"tableName": "messages",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `sender` TEXT NOT NULL, `senderEmail` TEXT NOT NULL, `subject` TEXT NOT NULL, `snippet` TEXT NOT NULL, `body` TEXT NOT NULL, `isHtml` INTEGER NOT NULL, `timestampMillis` INTEGER NOT NULL, `isRead` INTEGER NOT NULL, `isStarred` INTEGER NOT NULL, `folder` TEXT NOT NULL DEFAULT 'INBOX', `inInbox` INTEGER NOT NULL, `bodyFetched` INTEGER NOT NULL, `uid` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "id",
|
|
"columnName": "id",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "accountId",
|
|
"columnName": "accountId",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "sender",
|
|
"columnName": "sender",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "senderEmail",
|
|
"columnName": "senderEmail",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "subject",
|
|
"columnName": "subject",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "snippet",
|
|
"columnName": "snippet",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "body",
|
|
"columnName": "body",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "isHtml",
|
|
"columnName": "isHtml",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "timestampMillis",
|
|
"columnName": "timestampMillis",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "isRead",
|
|
"columnName": "isRead",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "isStarred",
|
|
"columnName": "isStarred",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "folder",
|
|
"columnName": "folder",
|
|
"affinity": "TEXT",
|
|
"notNull": true,
|
|
"defaultValue": "'INBOX'"
|
|
},
|
|
{
|
|
"fieldPath": "inInbox",
|
|
"columnName": "inInbox",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "bodyFetched",
|
|
"columnName": "bodyFetched",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "uid",
|
|
"columnName": "uid",
|
|
"affinity": "INTEGER",
|
|
"notNull": true,
|
|
"defaultValue": "0"
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"id"
|
|
]
|
|
},
|
|
"indices": [
|
|
{
|
|
"name": "index_messages_accountId",
|
|
"unique": false,
|
|
"columnNames": [
|
|
"accountId"
|
|
],
|
|
"orders": [],
|
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId` ON `${TABLE_NAME}` (`accountId`)"
|
|
},
|
|
{
|
|
"name": "index_messages_timestampMillis",
|
|
"unique": false,
|
|
"columnNames": [
|
|
"timestampMillis"
|
|
],
|
|
"orders": [],
|
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_timestampMillis` ON `${TABLE_NAME}` (`timestampMillis`)"
|
|
},
|
|
{
|
|
"name": "index_messages_accountId_folder_uid",
|
|
"unique": false,
|
|
"columnNames": [
|
|
"accountId",
|
|
"folder",
|
|
"uid"
|
|
],
|
|
"orders": [],
|
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_messages_accountId_folder_uid` ON `${TABLE_NAME}` (`accountId`, `folder`, `uid`)"
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"tableName": "attachments",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`messageId` TEXT NOT NULL, `partIndex` INTEGER NOT NULL, `filename` TEXT NOT NULL, `mimeType` TEXT NOT NULL, `sizeBytes` INTEGER NOT NULL, `contentId` TEXT, PRIMARY KEY(`messageId`, `partIndex`), FOREIGN KEY(`messageId`) REFERENCES `messages`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "messageId",
|
|
"columnName": "messageId",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "partIndex",
|
|
"columnName": "partIndex",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "filename",
|
|
"columnName": "filename",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "mimeType",
|
|
"columnName": "mimeType",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "sizeBytes",
|
|
"columnName": "sizeBytes",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "contentId",
|
|
"columnName": "contentId",
|
|
"affinity": "TEXT"
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"messageId",
|
|
"partIndex"
|
|
]
|
|
},
|
|
"indices": [
|
|
{
|
|
"name": "index_attachments_messageId",
|
|
"unique": false,
|
|
"columnNames": [
|
|
"messageId"
|
|
],
|
|
"orders": [],
|
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_attachments_messageId` ON `${TABLE_NAME}` (`messageId`)"
|
|
}
|
|
],
|
|
"foreignKeys": [
|
|
{
|
|
"table": "messages",
|
|
"onDelete": "CASCADE",
|
|
"onUpdate": "NO ACTION",
|
|
"columns": [
|
|
"messageId"
|
|
],
|
|
"referencedColumns": [
|
|
"id"
|
|
]
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"tableName": "outbox",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT NOT NULL, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastError` TEXT, `bodyHtml` TEXT, `attachments` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "id",
|
|
"columnName": "id",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "accountId",
|
|
"columnName": "accountId",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "toAddresses",
|
|
"columnName": "toAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "ccAddresses",
|
|
"columnName": "ccAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "bccAddresses",
|
|
"columnName": "bccAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true,
|
|
"defaultValue": "''"
|
|
},
|
|
{
|
|
"fieldPath": "subject",
|
|
"columnName": "subject",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "body",
|
|
"columnName": "body",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "createdAt",
|
|
"columnName": "createdAt",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "lastError",
|
|
"columnName": "lastError",
|
|
"affinity": "TEXT"
|
|
},
|
|
{
|
|
"fieldPath": "bodyHtml",
|
|
"columnName": "bodyHtml",
|
|
"affinity": "TEXT"
|
|
},
|
|
{
|
|
"fieldPath": "attachments",
|
|
"columnName": "attachments",
|
|
"affinity": "TEXT",
|
|
"notNull": true,
|
|
"defaultValue": "''"
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"id"
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"tableName": "drafts",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `accountId` TEXT, `toAddresses` TEXT NOT NULL, `ccAddresses` TEXT NOT NULL, `bccAddresses` TEXT NOT NULL DEFAULT '', `subject` TEXT NOT NULL, `body` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, `attachments` TEXT NOT NULL, `bodyHtml` TEXT, PRIMARY KEY(`id`))",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "id",
|
|
"columnName": "id",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "accountId",
|
|
"columnName": "accountId",
|
|
"affinity": "TEXT"
|
|
},
|
|
{
|
|
"fieldPath": "toAddresses",
|
|
"columnName": "toAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "ccAddresses",
|
|
"columnName": "ccAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "bccAddresses",
|
|
"columnName": "bccAddresses",
|
|
"affinity": "TEXT",
|
|
"notNull": true,
|
|
"defaultValue": "''"
|
|
},
|
|
{
|
|
"fieldPath": "subject",
|
|
"columnName": "subject",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "body",
|
|
"columnName": "body",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "updatedAt",
|
|
"columnName": "updatedAt",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "attachments",
|
|
"columnName": "attachments",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "bodyHtml",
|
|
"columnName": "bodyHtml",
|
|
"affinity": "TEXT"
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"id"
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"tableName": "folders",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `fullName` TEXT NOT NULL, `displayName` TEXT NOT NULL, `role` TEXT NOT NULL, `selectable` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `specialUse` INTEGER NOT NULL DEFAULT 0, `hierarchyDelimiter` TEXT, PRIMARY KEY(`accountId`, `fullName`))",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "accountId",
|
|
"columnName": "accountId",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "fullName",
|
|
"columnName": "fullName",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "displayName",
|
|
"columnName": "displayName",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "role",
|
|
"columnName": "role",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "selectable",
|
|
"columnName": "selectable",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "sortOrder",
|
|
"columnName": "sortOrder",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "specialUse",
|
|
"columnName": "specialUse",
|
|
"affinity": "INTEGER",
|
|
"notNull": true,
|
|
"defaultValue": "0"
|
|
},
|
|
{
|
|
"fieldPath": "hierarchyDelimiter",
|
|
"columnName": "hierarchyDelimiter",
|
|
"affinity": "TEXT"
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"accountId",
|
|
"fullName"
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"tableName": "backfill_progress",
|
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`accountId` TEXT NOT NULL, `folder` TEXT NOT NULL, `nextBeforeUid` INTEGER NOT NULL, `complete` INTEGER NOT NULL, PRIMARY KEY(`accountId`, `folder`))",
|
|
"fields": [
|
|
{
|
|
"fieldPath": "accountId",
|
|
"columnName": "accountId",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "folder",
|
|
"columnName": "folder",
|
|
"affinity": "TEXT",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "nextBeforeUid",
|
|
"columnName": "nextBeforeUid",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
},
|
|
{
|
|
"fieldPath": "complete",
|
|
"columnName": "complete",
|
|
"affinity": "INTEGER",
|
|
"notNull": true
|
|
}
|
|
],
|
|
"primaryKey": {
|
|
"autoGenerate": false,
|
|
"columnNames": [
|
|
"accountId",
|
|
"folder"
|
|
]
|
|
}
|
|
}
|
|
],
|
|
"setupQueries": [
|
|
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
|
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'f4b80b1d988222a691f7aeea4d45e37e')"
|
|
]
|
|
}
|
|
} |