[18.0] mail_composer_cc_bcc: IndexError in _prepare_outgoing_list when a recipient partner has no email — the whole email fails, not just that recipient
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 76/100
Research direction
Start with mail_composer_cc_bcc/models/mail_mail.py at line 76 and compare its recipient handling with core's odoo/addons/mail/models/mail_mail.py _prepare_outgoing_list. Reproduce with one partner with an email and one without, then add a regression test for the mixed-recipient case. Done means the invalid recipient is skipped without preventing valid recipients from receiving the mail.
Written by the indexing model from the issue text.
Description
Summary
When a message is sent through the mail composer and any recipient partner has no email address, MailMail._prepare_outgoing_list() raises
IndexError: list index out of range
at mail_composer_cc_bcc/models/mail_mail.py:76 (branch 18.0, 18.0.1.0.3). The exception escapes core's per-recipient handling, so the whole mail.mail fails: none of the valid recipients receives anything and the mail is left in exception state. Without this module installed, core handles the same situation gracefully — it skips the invalid recipient and delivers to everybody else.
Root cause
Core builds one outgoing entry per recipient partner, and for a partner without an email address it deliberately produces a placeholder (odoo/addons/mail/models/mail_mail.py, _prepare_outgoing_list):
email_to_normalized = tools.mail.email_normalize_all(partner.email)
email_to = [
tools.formataddr((partner.name or "", email or "False"))
for email in email_to_normalized or [partner.email]
]
With partner.email falsy, email_normalize_all() returns [], the comprehension iterates over [False] and formats the literal string "False". formataddr then splits on the last @ and reassembles, which for "False" gives an empty local part:
>>> tools.formataddr(("Recipient Without Email", "False"))
'"Recipient Without Email" <@False>'
>>> extract_rfc2822_addresses('"Recipient Without Email" <@False>')
[]
So the entry has a non-empty email_to list — it survives the if m["email_to"] check — but no extractable address, and
rcpt_to = extract_rfc2822_addresses(m["email_to"][0])[0] # <-- IndexError
raises. The @ is inserted by core's formataddr, not by this module.
Steps to reproduce
Clean database with only mail_composer_cc_bcc installed (it only depends on mail):
Partner = env["res.partner"]
p_ok = Partner.create({"name": "Recipient With Email", "email": "[email protected]"})
p_ko = Partner.create({"name": "Recipient Without Email"}) # no email
mail = env["mail.mail"].create({
"subject": "test",
"body_html": "<p>test</p>",
"recipient_ids": [(6, 0, (p_ok + p_ko).ids)],
})
mail.with_context(is_from_composer=True)._prepare_outgoing_list()
# -> IndexError: list index out of range
Through the UI: add both partners as followers of any record with a chatter, open the full composer (this module only acts in composition mode comment) and send. Nobody receives the message, including Recipient With Email.
Current vs expected behaviour
| Current (module installed) | Expected (= core behaviour) | |
|---|---|---|
| Recipient without email | IndexError |
skipped, notification classified mail_email_invalid |
Other recipients of the same mail.mail |
not delivered | delivered |
mail.mail state |
exception |
sent |
Core's contract for this case is explicit: IrMailServer._prepare_smtp_to_list() returns an empty list, assert smtp_to_list, self.NO_VALID_RECIPIENT fires, and mail/models/mail_mail.py::_send() catches that AssertionError specifically in order to "ignore error to avoid blocking delivery to next recipients, if any" (its own comment). This module short-circuits that path before core ever gets there.
Proposed fix
Leave the entry untouched instead of indexing blindly. That is not merely defensive — it is what restores the core behaviour, because core then finds no SMTP recipient for that entry, raises NO_VALID_RECIPIENT and skips only that recipient:
- rcpt_to = extract_rfc2822_addresses(m["email_to"][0])[0]
+ addresses = [
+ address
+ for address in extract_rfc2822_addresses(m["email_to"][0])
+ if tools.mail.email_normalize(address)
+ ]
+ if not addresses:
+ continue
+ rcpt_to = addresses[0]
The email_normalize() filter is a second belt: extract_rfc2822_addresses() alone already returns [] for the <@False> shape, but normalizing makes the criterion explicit and covers any other malformed value matching the extraction pattern.
Worth noting for whoever fixes it: do not give rcpt_to a fallback value instead. The entry would stay alive and get the whole audience written into its To header while its normalized recipient list stays empty — i.e. no SMTP envelope restriction.
Affected versions
- 18.0 (
18.0.1.0.3): line 76. - 19.0: the module is not on the
19.0branch yet, but the open migrations carry the same construct — #163 has it twice (email_toandemail_cc), and #104 / #189 once. I reproduced it on 19.0 running the code of the open migration, where core builds the identical<@False>placeholder, and pushed the fix above to the branch of #104 (camptocamp/mail#2), with a test.
Happy to open the equivalent PR for 18.0 if the approach is agreed.
- Dominant language
- Python
- Stars
- 36
- Forks
- 180
- Avg merge
- 4d 2h
- Merged PRs (30d)
- 14
Getting set up
- No Dockerfile or Docker Compose file
- No pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from OCA/mail
-
[18.0] mail_drop_target: uploading a file to a Discuss channel fails (_get_as_sudo_from_request_or_raise no longer exists)Possibly taken A pull request linked to this issue is open or already merged. Open
Difficulty 2/5 1-3 hours Newbie friendliness 76/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 77/100
Maintainers usually reply within 1 day
-
bug
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
Maintainers usually reply within 1 day
-
Activity creation without model_idPossibly taken @parabol123654 claimed this 4 days ago. Openbug
Difficulty 2/5 1-3 hours Newbie friendliness 25/100
Maintainers usually reply within 1 day
Similar issues
-
add entry to blockOpen
Difficulty 1/5 Under an hour Newbie friendliness 65/100
StevenBlack/hosts#3299 ·
-
`garak --report` overwrites the input report when its name has no ".report"Possibly taken @0z5a claimed this today. Openneeds-triage
Difficulty 2/5 Half a day Newbie friendliness 72/100
Maintainers usually reply within 2 days
-
plot_graph crashes when a mesh node has no m2m edgePossibly taken @tygvi claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
mllam/neural-lam#883 · 1 comment ·
Maintainers usually reply within 2 days
-
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
future-agi/future-agi#4092 · 1 comment ·
Maintainers usually reply within 1 day
-
transcript analytics: a text whose only protected spans the prose pass redacts is withheld wholeOpen
Difficulty 1/5 Under an hour Newbie friendliness 82/100
WingedGuardian/GENesis-AGI#4641 ·
Maintainers usually reply within 1 day