Skip to content

feat(mms): gateway-side attachment conversion and composer improvements - #103

Open
zhangsmallchi wants to merge 14 commits into
MddIdd:developfrom
zhangsmallchi:feat/mms-improvements
Open

zhangsmallchi wants to merge 14 commits into
MddIdd:developfrom
zhangsmallchi:feat/mms-improvements

Conversation

@zhangsmallchi

@zhangsmallchi zhangsmallchi commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

Based on develop after v1.11.0. The repair-only part of this work was merged in #111; this PR now carries only the enhancements and the follow-ups from review.

Summary

Moves MMS picture conversion from the browser to the gateway, so every client -- the WebUI, the API, later a SIP user agent -- gets the same checks and the same result. The WebUI uploads each attachment as it is added, shows its size before and after, and lets the sender send several attachments as one MMS or one MMS each.

Changes

  • Formats: one capability table (send / convert / receive-only) served in the MMS settings as formats; files are checked by content (HEVC video, vCard 4.0 and mislabelled files are refused with the reason). Builds on the naming helpers from fix(mms): preserve attachment integrity and complete backups #111 instead of adding its own.
  • Conversion: Pillow + pillow-heif convert HEIC/WebP/BMP/AVIF to JPEG and shrink pictures from their originals to fit; every picture sent loses its EXIF/XMP/IPTC (location) metadata; the pixel limit is read from the header before anything is decoded; video conversion has a slot but is not implemented.
  • Composing: new /mms/attachments endpoints stage uploads (originals kept while composing, 24 h TTL) and fit them; the sent message keeps only what was sent. Staging is bounded by count and by disk (64 MB per line, 256 MB per gateway), and /fit and attachment_ids are capped.
  • Upload limits: the request body is counted as it is read (wrapped ASGI receive) and cut off at the endpoint's limit, whether or not a Content-Length is present; fields (256 KB, 40), files per request and total size are all bounded.
  • One MMS or one each: the limit is per MMS; with several attachments the sender chooses; split messages are submitted in order.
  • Robustness: a truncated MP4/3GP box is refused instead of returning 500.
  • Install: install.sh reload stops if the new packages cannot be installed or imported, before anything restarts.
  • Docs: TROUBLESHOOTING MMS section, CHANGELOG (Unreleased), NOTICE entry for the pillow-heif wheel (libheif, libde265, x265).
  • WebUI: attachments upload as added with before/after sizes; parts the browser cannot show fall back to a download card.

Upgrade impact

  • Two new Python packages (Pillow 12.3.0, pillow-heif 1.7.0), installed from prebuilt wheels (amd64/arm64, glibc/musl) by install.sh reload; an offline host needs them available first, and a reload without them stops with the reason.
  • No schema change.
  • Browser-side picture compression is removed; older WebUIs keep working against the send endpoint.
  • QuickTime .mov stays receive-only; H.264/AAC .mov support is left for a separate PR under the conditions discussed in review.

Safety and privacy impact

  • No real SIM identity, phone number, PIN, token, subscription URL, message or call data is included (tools/check-subscriber-identifiers.sh --commits clean).
  • Device-changing operations fail closed and report actual state (no device-changing operations added).
  • User-visible changes are documented (TROUBLESHOOTING MMS section, CHANGELOG, NOTICE).
  • Python tests pass at every commit individually.

Testing

  • Python tests pass at every commit: 1,387 at the tip, 51 added by this PR. WebUI node tests: 18/18.
  • Test device: deployed, existing MMS read back, 12 MP JPEG fits in ~0.3 s, HEIC in ~0.8 s.
  • One MMS with several photos to a real phone (test device, build before the review follow-ups).
  • HEIC from an iPhone arrives as a viewable JPEG (same build; one of two sends hit a modem SEND FAIL mid-upload, unrelated to conversion and addressed in a separate PR).
  • One MMS per attachment to a real phone.
  • A converted HEIC carries no location.
  • HEVC video and vCard 4.0 are refused when added.

中文

基于 v1.11.0 之后的 develop。这项工作中的纯修复部分已通过 #111 合并;本 PR 现在只包含增强功能和评审后续修改。

概要

把彩信图片转换从浏览器移到网关:网页端、API 以及今后的 SIP 客户端都走同一套检查,得到同样的结果。网页端在添加附件时即上传,显示压缩前后的大小,多个附件可选择合并为一条或每个单独发送。

改动

  • 格式:统一的能力表(发送 / 转换 / 仅接收),随彩信设置以 formats 返回;按文件内容检查(HEVC 视频、vCard 4.0、内容与类型不符的文件会被拒绝并说明原因)。直接使用 fix(mms): preserve attachment integrity and complete backups #111 的命名辅助函数,不再自带一份。
  • 转换:用 Pillow + pillow-heif 把 HEIC/WebP/BMP/AVIF 转为 JPEG,并从原图压缩到放得下;发出的图片都去掉 EXIF/XMP/IPTC(位置)元数据;像素上限在解码前从文件头读取判断;视频转换预留接口,暂未实现。
  • 编写:新增 /mms/attachments 接口暂存上传(编写期间保留原图,24 小时后清理)并计算压缩结果;发送后只保存实际发出的版本。暂存区按数量和磁盘占用双重限制(每线路 64 MB、整机 256 MB),/fitattachment_ids 的数量设上限。
  • 上传限制:在读取请求体时计数(包装 ASGI receive),超过接口上限即中断,不论是否带 Content-Length;字段(256 KB、40 个)、单请求文件数和总大小都有上限。
  • 合并或单独发送:上限是每条彩信一个;多个附件时由发送者选择;单独发送时按顺序依次提交。
  • 健壮性:截断的 MP4/3GP 盒子会被拒绝,不再返回 500。
  • 安装:新依赖装不上或无法导入时,install.sh reload 在重启任何服务之前中止。
  • 文档:TROUBLESHOOTING 彩信一节、CHANGELOG(Unreleased)、NOTICE 补充 pillow-heif wheel 所带的库(libheif、libde265、x265)。
  • 网页端:附件添加即上传并显示压缩前后大小;浏览器无法显示的附件改为下载卡片。

升级影响

  • 新增两个 Python 包(Pillow 12.3.0、pillow-heif 1.7.0),install.sh reload 从预编译 wheel 安装(amd64/arm64、glibc/musl);离线主机需要事先准备,缺少时 reload 会中止并说明原因。
  • 无数据库结构变更。
  • 去掉了浏览器端图片压缩;旧版网页端仍可使用发送接口。
  • QuickTime .mov 仍为仅接收;H.264/AAC .mov 的支持按评审中讨论的条件另开 PR。

安全与隐私影响

  • 不含真实 SIM 身份、电话号码、PIN、令牌、订阅链接、短信或通话数据(tools/check-subscriber-identifiers.sh --commits 通过)。
  • 修改设备的操作失败时关闭并报告实际状态(未新增此类操作)。
  • 用户可见的变化已写入文档(TROUBLESHOOTING 彩信一节、CHANGELOG、NOTICE)。
  • Python 测试逐个 commit 通过。

测试

  • 每个 commit 的 Python 测试均通过:末端共 1,387 个,本 PR 新增 51 个。网页端 node 测试:18/18。
  • 测试机:已部署,现有彩信可读,1200 万像素 JPEG 约 0.3 秒、HEIC 约 0.8 秒。
  • 多张照片合并为一条发到真机(测试机,评审后续修改之前的版本)。
  • iPhone 的 HEIC 照片到达后为可查看的 JPEG(同一版本;两次发送中有一次上传途中模块返回 SEND FAIL,与转换无关,另开 PR 处理)。
  • 每个附件单独一条发到真机。
  • 转换后的 HEIC 不含位置信息。
  • 添加 HEVC 视频和 vCard 4.0 时即被拒绝。

🤖 Generated with Claude Code

@MddIdd

MddIdd commented Sep 21, 2026

Copy link
Copy Markdown
Owner

感谢你在 #103 里集中修复并增强 MMS 功能。为了降低这次发版的回归风险,我们计划先从当前 PR 中拆出已经比较明确的纯修复部分,单独提交一个 repair-only PR:

  • ab45c36:会话默认打开到最新消息;
  • e28138c:用 XML 库生成 SMIL,并保证各 part 的引用唯一;
  • 393443b:按完整封装后的 m-send-req 检查线路大小限制;
  • 0b21cf8:附件使用内部文件名,并在数据库提交后原子切换;
  • e0544cd:备份时包含 MMS 附件。

为使上述提交能够各自独立检出和测试,我们还会从 7a8f802 中只抽取附件命名函数和 MIME 映射,放在 0b21cf8 之前;不会带入图片转换、Pillow 或依赖项改动。拆分过程中会保留你在原提交中的作者署名,并在抽取提交中保留 Co-authored-by

纯修复合并后,希望你可以把 #103 rebase 成只保留增强部分。我们建议增强部分在下一个版本继续优化和验证,当前待办包括:

  • Pillow 解压炸弹限制目前要超过 MAX_IMAGE_PIXELS 的 2 倍才会报错;
  • max_part_size 目前限制错了对象,没有真正限制文件上传;
  • /fitids 数量没有上限;
  • 暂存区缺少全局磁盘占用上限;
  • _inspect_iso 遇到截断输入可能越界并返回 500;
  • 依赖安装失败时必须中断 reload;
  • iPhone HEVC .mov 的兼容策略(待维护者决定);
  • NOTICE 需要补充 x265、libheif、libde265。

这是为了尽快、安全地发布修复,同时给增强部分留出充分的真机验证时间。请看看这个拆分方案是否合适;如有不同意见,请在 3 天内(9 月 25 日前)回复。若无异议,我们会在期限后推送并提交 repair-only PR。


Thank you for collecting the MMS fixes and enhancements in #103. To reduce regression risk for this release, we plan to first extract the well-scoped fixes into a separate repair-only PR:

  • ab45c36: open conversations at the newest message;
  • e28138c: generate SMIL with an XML library and keep part references unique;
  • 393443b: enforce the line size limit against the fully packaged m-send-req;
  • 0b21cf8: use internal attachment names and switch files atomically after the database commit;
  • e0544cd: include MMS attachments in backups.

To keep every extracted commit independently checkoutable and testable, we will also extract only the attachment naming helpers and MIME mapping from 7a8f802 and place that commit before 0b21cf8. This will not include image conversion, Pillow, or dependency changes. We will preserve your authorship on the original commits and retain a Co-authored-by trailer on the extracted helper commit.

After the repair-only PR is merged, we would appreciate it if you could rebase #103 so that it contains only the enhancement work. We suggest optimizing and validating those enhancements for the next release. The current follow-up list is:

  • Pillow's decompression-bomb check currently raises only above twice MAX_IMAGE_PIXELS;
  • max_part_size currently targets the wrong object and does not actually limit file uploads;
  • /fit has no upper bound on the number of ids;
  • the staging area has no global disk-usage limit;
  • _inspect_iso can read out of bounds on truncated input and return 500;
  • a dependency-install failure must abort reload;
  • compatibility policy for iPhone HEVC .mov files (maintainer decision pending);
  • NOTICE needs entries for x265, libheif, and libde265.

This lets us ship the fixes quickly and safely while leaving enough time for real-device validation of the enhancements. Please let us know whether this split works for you. If you disagree, please reply within three days (by September 25). Otherwise, we will push and open the repair-only PR after that deadline.

@zhangsmallchi

Copy link
Copy Markdown
Contributor Author

The split works for us — no objection, please go ahead. Once the repair-only PR is merged we
will rebase #103 onto develop so it carries only the enhancement commits, and drop the
naming and MIME helpers you extract from 7a8f802.

Seven of the eight follow-ups are now on the branch (297485e..dee1e61), each with tests; the
Python suite passes at every commit individually and
tools/check-subscriber-identifiers.sh --commits develop..HEAD is clean.

  • f6b87d6 — the decompression-bomb limit. Confirmed, and worth stating plainly: setting
    Image.MAX_IMAGE_PIXELS is not setting a limit. Pillow warns between that number and twice
    it and raises only above, so a file claiming 96 MP passed the 64 MP setting and was decoded
    in full — about 380 MB of pixels from a few hundred bytes of header. The size is now read
    from the header after Image.open and before anything is decoded; Pillow's own guard stays
    as a backstop.
  • 46d85acmax_part_size. The finding is sharper than "the wrong object": starlette
    applies it only to the fields it keeps in memory. Passing the upload limit therefore raised
    the ceiling on the text and recipients from 1 MB to 25 MB and did nothing whatever for
    files, which are streamed to a spooled temporary file and measured only once they have been
    written — twenty of them in one request. The fields now have a limit of their own (256 KB),
    and the declared body length is checked before parsing starts: one upload's worth for
    /mms/attachments, which also now takes one file rather than twenty, and a line's staging
    budget for /mms/send. One behaviour change to flag: a multipart upload that declares no
    Content-Length is refused with 411 rather than parsed. Nothing sits in front of uvicorn
    here, so the declared length is reliable and h11 will not deliver more than it.
  • b45f109 — the ids list. Capped at MAX_PER_LINE, and the same cap applied to
    attachment_ids on send, which had the same hole. Ids are not deduplicated, so naming one
    25 MB upload a thousand times was a way to ask for gigabytes from a request of a few
    kilobytes.
  • 2d444cc — the staging area's disk use. 64 MB per line and 256 MB for the gateway,
    checked before the write; an abandoned draft is swept before the room is declared full. The
    upload is now written while the budget is held, so two concurrent uploads cannot both pass
    the same check.
  • 05648ab_inspect_iso. Reproduced: an ftyp followed by a moov holding an empty
    mvhd raises IndexError on the version byte, which reaches the client as a 500. Each
    field is read only once the box is long enough to hold it, and the file is then refused by
    the checks that follow — it has no track.
  • 692aec5 — a failed dependency install. setup_venv now fails with a reason instead of
    leaving it to set -e, and proves the dependencies import before anything is restarted.
    Installed is not the same as usable for a wheel carrying native libraries, and the control
    plane starts without Pillow on purpose, so a reload could otherwise come up with picture
    conversion silently off.
  • dee1e61 — NOTICE. It now carries the pillow-heif paragraph (libheif and libde265
    LGPL-3.0, x265 GPL-2.0-or-later) beside the Asterisk and pjproject one.
    THIRD_PARTY_LICENSES.md already had the table entry; NOTICE is the file that states the
    obligation, and it said nothing.

On iPhone HEVC .mov, our recommendation is to keep refusing it in this release. Video
conversion needs ffmpeg in the control plane, which is a dependency with its own licensing
consequences for a distributed image, and an MMS video budget of a few hundred kilobytes is
not going to produce a clip worth watching from an iPhone original. The current message
already tells the sender what to do ("export it as H.264 video with AAC or AMR sound"), and
iOS can be set to record in the compatible format.

There is a separate, smaller problem next to it that we would rather you decided on as well.
An iPhone .mov holding H.264 and AAC is refused today too, not because of its codecs but
because of its brand: check_attachment maps ftyp qt to video/quicktime, which the
capability table lists as receive-only. The same tracks in a file branded mp42 are accepted
as video/mp4.

qt   -> "clip.mov: video/quicktime cannot be sent by MMS"
mp42 -> video/mp4, accepted

Those files are common — "Most Compatible" on iOS, older devices, a lot of cameras — so this
refuses content that is already sendable. The cheapest honest fix is a video converter in the
slot that already exists: when every track is a codec the table can send, rewrite the ftyp
brand to mp42 and send it as video/mp4. It is a four-byte change to the file, needs no
ffmpeg, and leaves HEVC refused exactly as it is now. We are happy to do it for the next
release if you agree it belongs there, and equally happy to leave it alone.


中文

拆分方案我们没有异议,请按计划推进。repair-only PR 合并后,我们会把 #103 rebase 到 develop
只保留增强部分,并去掉你们从 7a8f802 中抽取的命名与 MIME 辅助代码。

八条待办中的七条已经在分支上(297485e..dee1e61),每条都带测试;Python 测试在每个提交上单独
通过,tools/check-subscriber-identifiers.sh --commits develop..HEAD 干净。

  • f6b87d6 — 解压炸弹限制。 确认存在,而且值得说清楚:设置 Image.MAX_IMAGE_PIXELS 并不
    等于设置了限制。Pillow 在该值与其两倍之间只告警,超过两倍才抛异常,所以一个声称 96 MP 的文件
    能通过 64 MP 的设置并被完整解码——几百字节的文件头换来约 380 MB 的像素。现在在 Image.open
    之后、解码任何数据之前直接从文件头读取尺寸;Pillow 自己的守卫保留为兜底。
  • 46d85acmax_part_size 实际情况比"限制错了对象"更严重:starlette 只用它限制保存在
    内存里的字段。把上传上限传进去,等于把正文和收件人的上限从 1 MB 抬到 25 MB,而对文件毫无作用
    ——文件部分被直接流式写入临时文件,只有写完之后才被测量,一个请求里可以有二十个。现在字段有
    自己的上限(256 KB),并在解析开始前检查声明的请求体长度:/mms/attachments 只允许一个文件、
    一个上传的体积,/mms/send 按线路的暂存预算。一处行为变化需要说明:不带 Content-Length
    multipart 上传现在返回 411 而不是继续解析。uvicorn 前面没有任何反向代理,所以声明的长度可信,
    h11 也不会交付超出声明长度的数据。
  • b45f109ids 列表。MAX_PER_LINE 设上限,发送接口的 attachment_ids 有同样的
    问题,一并处理。id 不去重,所以把同一个 25 MB 的上传写一千遍,就能用几 KB 的请求要走几 GB。
  • 2d444cc — 暂存区磁盘占用。 每条线路 64 MB、整机 256 MB,在写入前检查;宣布放不下之前
    先清理被遗弃的草稿。上传现在在持有预算的同时写入,两个并发上传不会同时通过同一次检查。
  • 05648ab_inspect_iso 已复现:ftyp 后跟一个内容为空的 mvhd,读版本字节时抛
    IndexError,到客户端就是 500。现在每个字段只在盒子长度足够时才读取,文件随后被后续检查以
    "没有音视频轨道"拒绝。
  • 692aec5 — 依赖安装失败。 setup_venv 现在自己报出原因,而不是交给 set -e,并在重启
    任何东西之前验证依赖可以导入。对带原生库的 wheel 来说,装上了不等于能用,而控制面是有意在缺少
    Pillow 时照常启动的——否则 reload 会在图片转换静默失效的状态下起来。
  • dee1e61 — NOTICE。 现在在 Asterisk / pjproject 那一段旁边加上了 pillow-heif 的说明
    (libheif、libde265 为 LGPL-3.0,x265 为 GPL-2.0-or-later)。THIRD_PARTY_LICENSES.md 原本就
    有表格条目,而 NOTICE 才是陈述分发义务的文件,此前只字未提。

关于 iPhone 的 HEVC .mov,我们的建议是这个版本继续拒绝。视频转换需要在控制面引入 ffmpeg,
对分发的镜像有自己的授权后果;而彩信只有几百 KB 的预算,从 iPhone 原片转出来的片子也不会有人愿意
看。现在的提示已经告诉了发送者该怎么做(导出为 H.264 + AAC 或 AMR),iOS 本身也可以设置为录制
兼容格式。

旁边还有一个更小的问题,我们同样希望由你们决定。今天 含 H.264 和 AAC 的 iPhone .mov 也会被
拒绝,原因不是编码而是 brand:check_attachmentftypqt 的文件判为
video/quicktime,而能力表把它列为仅接收。同样的轨道,如果 brand 是 mp42,就会作为
video/mp4 通过。

qt   -> "clip.mov: video/quicktime cannot be sent by MMS"
mp42 -> video/mp4,通过

这类文件很常见——iOS 的"兼容性最佳"、老一些的设备、很多相机——于是被拒绝的是本来就能发的内容。
最省事又诚实的修法,是用已经预留好的视频转换器槽位:当所有轨道都是能力表可发送的编码时,把
ftyp brand 改写为 mp42,按 video/mp4 发出。这是对文件的四个字节的改动,不需要 ffmpeg,
HEVC 仍然照旧被拒绝。如果你们认为它属于下个版本,我们乐意去做;认为不必要,我们也乐意就此打住。

@MddIdd

MddIdd commented Sep 22, 2026

Copy link
Copy Markdown
Owner

感谢确认拆分方案并完成后续修改。纯修复部分已经通过 #111 合并,并随 v1.11.0 发布。

下一步请把 #103 rebase 到最新的 develop,删除已经通过 #111 合并的修复提交,以及随拆分一并抽取的命名/MIME 辅助代码,使这个 PR 只保留增强功能和针对审查意见的后续修改。整理完成后,请同步更新 PR 描述、测试数量和变更清单,再请求复审。我们不会基于当前混合的新旧历史直接合并。

你新增的七项修改总体上覆盖了此前提出的问题,不过 46d85ac 的上传限制实现还需要调整:

  • 项目支持通过反向代理访问;合法的 chunked 或 HTTP/2 multipart 请求可能没有 Content-Length,因此不能统一返回 411。
  • 仅检查客户端声明的 Content-Length 也不能可靠限制实际接收的字节数。
  • 请在实际读取请求流时统计并限制字节数,例如包装 ASGI receive 或采用等效方案;同时保留字段数量、字段大小、文件数量以及请求总大小限制。

关于视频格式:

  • 同意下一版本继续拒绝 HEVC .mov,暂不引入 ffmpeg 转码。
  • 对 H.264 + AAC 的 QuickTime .mov,原则上可以考虑无转码兼容,但不能仅凭编解码器类型就直接修改四字节 brand 并声明为 MP4。建议满足以下条件:
    1. 解析并确认只包含受支持的轨道,未知、损坏或 QuickTime 专有结构应安全拒绝;
    2. 在暂存区生成新的 .mp4 / video/mp4 派生文件,不修改用户的原始文件;
    3. 增加测试样本,覆盖 H.264/AAC QuickTime 接受、HEVC 继续拒绝,以及损坏或截断文件拒绝;
    4. 使用真实 iPhone、Android 和运营商 MMS 做发送验证;
    5. 如果修改 brand 无法保证代表性样本符合 MP4 结构,就先推迟支持,避免仅修改标签却产生不兼容文件。

这个增强 PR 计划放到 v1.11.0 之后的版本评估。完成 rebase 和上传限制调整后请通知我们,我们会重新审查。


Thank you for confirming the split and implementing the follow-up changes. The repair-only portion has already been merged through #111 and released in v1.11.0.

Please now rebase #103 onto the latest develop and remove the repair commits already merged through #111, along with the naming/MIME helper code extracted during the split. This PR should contain only the enhancement work and its review follow-ups. Once reorganized, please update the PR description, test count, and change list, then request another review. We will not merge the current mixed old/new history directly.

The seven new changes generally address the issues previously identified. However, the upload-limit implementation in 46d85ac still needs revision:

  • The project supports reverse-proxy deployments. Valid chunked or HTTP/2 multipart requests may not contain Content-Length, so they must not be rejected with 411 solely for that reason.
  • Checking only the client-declared Content-Length does not reliably limit the number of bytes actually received.
  • Please enforce the limit while reading the request stream—for example, by wrapping the ASGI receive callable or using an equivalent mechanism—while retaining limits for field count, field size, file count, and total request size.

Regarding video formats:

  • We agree that HEVC .mov should remain unsupported in the next release, without introducing ffmpeg transcoding.
  • H.264 + AAC QuickTime .mov may be considered for remux-free compatibility, but changing only the four-byte brand and declaring the file to be MP4 is not sufficient based solely on codec detection. We suggest the following requirements:
    1. Parse the file and confirm that it contains only supported tracks; unknown, malformed, or QuickTime-specific structures must fail closed.
    2. Produce a new staged .mp4 / video/mp4 derivative rather than modifying the original file.
    3. Add fixtures covering accepted H.264/AAC QuickTime files, continued rejection of HEVC, and rejection of malformed or truncated files.
    4. Validate sending through real iPhone, Android, and carrier MMS environments.
    5. If rewriting the brand does not establish MP4 compatibility for representative samples, defer the feature instead of merely relabeling incompatible files.

This enhancement PR will be evaluated for a release after v1.11.0. Please notify us after the rebase and upload-limit revision are complete, and we will review it again.

zhangsmallchi and others added 14 commits September 23, 2026 18:55
…lly is

The gateway accepted any image/*, audio/* or video/* upload on the strength of
the MIME type the client declared, so a HEVC video, a WebP picture or a file
merely named .jpg went to the MMSC as is; the WebUI's picker and drop filter
followed a different list again.

- mms_media.FORMATS lists each type with a policy -- send, convert (a client
  re-encodes it first; the gateway refuses it unconverted), receive (stored
  when it arrives, never sent) -- and whether a browser can preview it. The
  sendable set is small on purpose: JPEG/GIF/PNG, AMR/AMR-WB/MP3/AAC, H.263/
  MPEG-4/H.264 in 3GP or MP4, text, vCard 2.1/3.0, vCalendar/iCalendar.
- check_attachment() reads the content: magic numbers, the codecs an MP4/3GP
  file declares (HEVC, AV1 and encrypted tracks are refused with a hint), a
  card's VERSION. The content decides the type within a kind of media; a file
  whose content is another kind than declared is refused as mislabelled.
  Audio/video playing time (MP4 mvhd, AMR frames) feeds the SMIL slide length.
- Composing and storing both go through check_attachments(), so a client
  calling the API directly gets the same checks as the WebUI. Names are made
  safe by display_name(): byte-limited, extension kept, paths and control
  characters removed.
- The MMS settings response carries the table ("formats") and every stored
  part says whether it can be previewed, for the WebUI and future clients such
  as a SIP user agent submitting MMS through the gateway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ine's limit

Pictures were only resized in the browser, so a client calling the API -- and
later a SIP user agent -- sent camera originals as they were, and formats most
phones cannot show (HEIC, WebP, AVIF) could not be sent at all.

- mms_convert holds converters by kind of media. The image converter (Pillow,
  with pillow-heif for HEIC/HEIF) decodes any supported picture, applies its
  EXIF orientation, flattens transparency onto white and writes baseline JPEG
  without metadata, searching the largest size (1600 px down) and then the
  highest quality that fits a byte budget. A JPEG, PNG or GIF that already
  fits and is no larger than 1600 px keeps its pixels, but loses its EXIF,
  XMP, IPTC or PNG text -- where a photo was taken travels in its EXIF -- and
  one whose EXIF rotates it is re-encoded upright instead. An animated GIF is
  never re-encoded. A video converter can register under "video" with the same
  interface later; nothing else has to change for it.
- mms.fit_attachments() plans the message: sound, video and animated GIFs
  count as they are, the rest of the limit after them, the text and the
  packaging is shared between the pictures (a small one keeps its size), each
  is fitted from its original, and the packaged result is measured and the
  shares tightened if needed. It reports each attachment's size before and
  after. prepare_outgoing() is what sending uses; a "convert" format is taken
  whenever a converter exists for it, and the capability table's new
  "attachable" flag says so to clients.
- Uploads may now be up to 25 MB each, since the gateway shrinks them.
- Pillow 12.3.0 and pillow-heif 1.7.0 have wheels for every supported
  platform (amd64/arm64, glibc/musl, Python 3.12/3.13); licenses listed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
To show the user what an attachment will cost as soon as it is added, the
client uploads it then, and the gateway keeps the original until the message
is sent or the attachment removed. Each re-plan -- another picture added, one
removed -- starts again from the original rather than from an earlier,
smaller encoding. Once sent, only what was sent is stored with the message.

- POST   /api/instances/{id}/mms/attachments        upload one file (checked
                                                     at once; refused formats
                                                     never get staged)
- POST   /api/instances/{id}/mms/attachments/fit    {ids, text, subject, to}:
                                                     each size before/after
                                                     and the packaged total
- GET    /api/instances/{id}/mms/attachments/{aid}/preview   the fitted picture
- DELETE /api/instances/{id}/mms/attachments/{aid}
- POST   /api/instances/{id}/mms/send takes attachment_ids besides files, fits
  everything again with the final text and recipients, and removes the
  uploads it sent.

Uploads live under <data>/mms-staging/<line>/, at most 20 per line, and are
swept a day after their last use (the MMS worker's sweep now runs hourly).
The full local backup leaves them out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ill be sent

Picture compression now happens on the gateway, so the browser's own is gone
(mmsImage.js). Instead:

- Each attachment is uploaded as soon as it is added (picker, paste or drop);
  a file the gateway refuses is dropped at once with its reason.
- After every change the composer asks the gateway to fit the message and
  shows each attachment's size before and after ("3.2 MB -> 180 KB"), its
  converted thumbnail, and the packaged total against the line's limit, with
  the gateway's reason when it does not fit. Edits to the text, subject or
  recipient refit after a pause. Send waits for uploads and fits to settle and
  sends the staged attachment ids; removing an attachment or leaving the line
  deletes its upload.
- The picker's accept list, paste and drag-drop follow the gateway's
  capability table (mmsFormats.js): what it marks attachable -- including the
  pictures it converts (HEIC, WebP, BMP, AVIF) -- matched by type, alias or,
  when the browser gives no type, extension. An older gateway without the
  table gets a built-in copy of the send-as-is formats.
- A received or sent part is shown inline only when the gateway marks it
  previewable and, for sound and video, this browser can play the type; if it
  still fails to load it becomes the download card, which says the preview is
  not available.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A line's size limit applies to each MMS, so pictures sent together share it
and each comes out smaller. The sender can now choose to send every
attachment as its own MMS, each fitted to the whole limit.

- mms.plan_messages() plans either mode from the originals: one message whose
  attachments share the limit, or one message per attachment with the text
  and subject on the first. Its summary reports each message's packaged size
  and whether it fits, besides the per-attachment sizes.
- The fit endpoint and POST .../mms/send take "split"; sending stores one
  message per attachment and submits them one after another, so they arrive
  in order. The response lists them all ("message" stays the first).
- The same attachment fits differently in the two modes, so a staged upload
  keeps its last few fitted versions under a digest of their content; each fit
  returns that token per attachment and the preview endpoint serves exactly
  that version, never the other mode's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With two or more attachments the composer offers "One MMS" (the default) and
"One MMS per attachment". Switching re-plans every attachment on the gateway
from its original; the thumbnails come from the fitted version that plan
produced (its preview token) and sending waits for the new plan. The total
line reads "N MMS, each up to <limit>" when split, and the choice resets
after sending.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…imit and backups

TROUBLESHOOTING's MMS section lists the send / convert / receive-only formats
and common refusals, explains how the gateway converts and shrinks pictures
while a message is composed (and what it keeps afterwards), that the size
limit is per MMS and covers the packaged message, and the choice between one
MMS and one per attachment; it names the encapsulation version and SMIL
layout targeted (without claiming conformance), and explains where
attachments live and how to restore them from a pre-upgrade backup.
CHANGELOG records the changes and the two new Python packages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An upload cut off part way through still parses as ISO boxes, so the movie
header is present but empty. Reading its version byte then raised IndexError,
which reaches the client as a 500 instead of the reason the file cannot be
sent.

Each field is now read only once the box is long enough to hold it. A header
that stops short has no duration, which is what a file without one already
gave the caller, and the file itself is refused by the checks that follow --
it has no track.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Setting Image.MAX_IMAGE_PIXELS reads as a limit but is not one: Pillow only
warns between that number and twice it, and raises above. A file claiming 96
megapixels therefore passed a 64-megapixel setting and was decoded in full --
about 380 MB of pixels for a few hundred bytes of header.

The size is now read from the header, after opening and before anything is
decoded, and a picture over MAX_PIXELS is refused with what it claims to be.
Pillow's own guard stays as a backstop.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fitting and sending both took the list of staged attachment ids as given.
Every id is read into memory to be fitted, and nothing said an id could not
appear twice, so a list naming one 25 MB upload a thousand times asked the
gateway for gigabytes -- from a request of a few kilobytes.

Both endpoints now refuse a list longer than the number of uploads a line is
allowed to hold at once, which is already the most a composer can name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A line could hold MAX_PER_LINE uploads, and nothing said how large they were
allowed to be: twenty files of the largest size the API accepts are half a
gigabyte, per line, on an appliance whose disk is often an SD card. The
gateway as a whole had no ceiling at all.

An upload is now refused unless the line, and the gateway, have room for it
(64 MB and 256 MB). An abandoned draft is swept before the room is declared
full, so a gateway left at the ceiling yesterday still takes an attachment
today. The whole upload is written while the budget is held, so what the next
one measures is what is really there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
``max_part_size`` was given the upload limit, which reads as a 25 MB ceiling
on an attachment and is neither. starlette applies it only to the fields it
keeps in memory, so the effect was to raise the ceiling on the text and
recipients from 1 MB to 25 MB; a file part is streamed to a spooled temporary
file with no limit at all and was measured only after it had been written.
Twenty files in one request could therefore fill the disk before a single
check ran.

The fields get a limit of their own (256 KB, more than an MMS can carry as
text), the attachment endpoint takes one file, and the body is counted as it
is read: the ASGI receive callable is wrapped, and parsing stops at the first
chunk that takes the request past its limit -- one upload's worth for an
attachment, what the staging area lets a line hold for sending. A declared
Content-Length is only an early refusal when it is already too large. It is
not required, since a chunked or HTTP/2 upload through a reverse proxy may
carry none, and it is not trusted, since nothing obliges the body to match it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ncies

The control plane starts without Pillow and pillow-heif on purpose: MMS
picture conversion is then simply not done. That is the right behaviour for a
process that is already running and the wrong outcome for a reload, which
would restart into a gateway that converts nothing and says so nowhere until
somebody sends a photo.

setup_venv now says what it means instead of leaving it to `set -e`, and
proves the dependencies import before the control plane is restarted --
installed is not the same as usable for a wheel carrying native libraries.
Either failure stops the reload with the reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
THIRD_PARTY_LICENSES.md has the entry; NOTICE, which is the file that says
what a distributed build obliges the distributor to offer, did not mention it
at all. The wheel bundles libheif and libde265 (LGPL-3.0) and x265
(GPL-2.0-or-later), so a host install carrying it is in the same position as
the engine image's Asterisk and pjproject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@zhangsmallchi zhangsmallchi changed the title feat(mms): gateway-side attachment conversion, SMIL/size/storage fixes and composer improvements feat(mms): gateway-side attachment conversion and composer improvements Sep 23, 2026
@zhangsmallchi

zhangsmallchi commented Sep 23, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto develop (916f200) and ready for another review.

  • History: 14 commits, enhancement work and review follow-ups only. The commits merged through fix(mms): preserve attachment integrity and complete backups #111 are gone, and so is the second copy of the naming/MIME helpers: mms_media.py keeps display_name/storage_name exactly as merged through fix(mms): preserve attachment integrity and complete backups #111 (5b39484), and the capability table replaces the _EXTENSIONS map as the source of file_extension (same extension for every type). The CHANGELOG entries moved to a new [Unreleased] section, without the fixes already listed under 1.11.0.
  • Upload limit (9eb5285, rewritten in place): the 411 is gone. _mms_form wraps the ASGI receive and counts body bytes as the parser pulls them, stopping with 413 at the first chunk past the endpoint's limit, so chunked and HTTP/2 uploads through a proxy work and a body cannot exceed its declared length. A declared Content-Length is only an early 413 when it is already too large. Field size (256 KB), field count (40), files per request (1 for /mms/attachments, MAX_PER_LINE for /mms/send) and total size are all still enforced; starlette closes the spooled files it had begun when reading stops. Tests cover an upload with no Content-Length, a body that understates or omits its length and is cut off early, an oversized declaration refused before reading, and the field/file limits.
  • Video: HEVC .mov stays refused. We will not add the H.264/AAC QuickTime path to this PR; if we take it on, it will be a separate PR meeting your five conditions, and we will drop it if the relabelled files do not hold up as MP4.
  • Tests: the Python suite passes at every commit (1,387 tests at the tip, 51 added by this PR); tools/check-subscriber-identifiers.sh --commits develop..HEAD is clean. PR description, change list and test count are updated.
  • Device test: one MMS with several photos and HEIC→JPEG both went through on the test device. One HEIC send failed when the modem answered SEND FAIL part way through the upload -- unrelated to conversion; that is fixed separately in fix(mms): submit again when the modem cuts an upload short #116.

中文

已 rebase 到 develop916f200),请再次审查。

  • 历史:共 14 个提交,只包含增强功能和评审后续修改。通过 fix(mms): preserve attachment integrity and complete backups #111 合并的提交已删除,重复的一份命名/MIME 辅助代码也已去掉:mms_media.py 原样沿用经 fix(mms): preserve attachment integrity and complete backups #1115b39484)合并的 display_name/storage_name,能力表取代 _EXTENSIONS 映射作为 file_extension 的来源(每种类型的扩展名不变)。CHANGELOG 条目移到新的 [Unreleased] 一节,并去掉了 1.11.0 已列出的修复。
  • 上传限制(9eb5285,原地重写):去掉了 411。_mms_form 包装 ASGI receive,在解析器读取时统计请求体字节,超过接口上限的第一个分块即以 413 中止;因此经代理的 chunked 和 HTTP/2 上传可以正常使用,请求体也无法超出声明的长度。声明的 Content-Length 只在本身已经超限时用于提前返回 413。字段大小(256 KB)、字段数量(40)、单请求文件数(/mms/attachments 为 1,/mms/sendMAX_PER_LINE)和请求总大小仍然全部生效;读取中止时 starlette 会关闭已开始写入的临时文件。测试覆盖:不带 Content-Length 的上传、少报或不报长度的请求体被提前截断、声明长度超限时不读取即拒绝,以及字段/文件数量限制。
  • 视频:HEVC .mov 继续拒绝。H.264/AAC 的 QuickTime 支持不放进本 PR;如果要做,会另开 PR 并满足你们提出的五项条件,改写 brand 后若不能保证 MP4 结构就放弃。
  • 测试:Python 测试在每个提交上都通过(末端共 1,387 个,本 PR 新增 51 个);tools/check-subscriber-identifiers.sh --commits develop..HEAD 干净。PR 描述、变更清单和测试数量已更新。
  • 真机测试:多图合并为一条、HEIC 转 JPEG 在测试机上均发送成功。有一次 HEIC 发送因上传途中模块返回 SEND FAIL 而失败,与转换无关,已另开 fix(mms): submit again when the modem cuts an upload short #116 修复。

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