Skip to content

refactor(ext.smtp): cleanup ext smtp#770

Open
derks wants to merge 7 commits intomainfrom
refactor/cleanup-ext-smtp
Open

refactor(ext.smtp): cleanup ext smtp#770
derks wants to merge 7 commits intomainfrom
refactor/cleanup-ext-smtp

Conversation

@derks
Copy link
Member

@derks derks commented Mar 9, 2026

Summary

Comprehensive bugfix, refactor, and deprecation pass on the SMTP mail extension (cement/ext/ext_smtp.py).

Bugs Fixed

  • timeout passed as local_hostname — SMTP/SMTP_SSL constructors received timeout as the 3rd positional arg, which is actually local_hostname; now passed as keyword arg
  • Stale variable reference in _get_params — per-message headers (date, message_id, return_path, reply_to) could silently receive the wrong value from a leftover loop variable
  • SMTP connection leak — if login(), _make_message(), or send_message() raised, server.quit() was never called; now wrapped in try/finally
  • Unconditional error log — app.log.error() fired on every send, including successful ones; now only logs when there are actual errors
  • Header/body encoding cross-contamination — header_encoding charset was incorrectly set to QP when only body_encoding was quoted-printable

Refactoring

  • Decomposed _make_message (150-line monolith) into focused private methods: _build_charsets, _build_body_parts, _build_mime_structure, _set_headers, _attach_body, _attach_files
  • PEP 8 naming — partText/partHtml → part_text/part_html
  • Idiomatic Python — str.strip(x) → x.strip(); redundant type() check replaced with else clause on existing isinstance chain
  • X-header normalization — consolidated in _get_params (underscores → hyphens), removed .title() call that could mangle acronyms (e.g. X-API-Key → X-Api-Key)

Test Improvements

  • Replaced shared mutable module-level defaults dict with a _get_defaults() helper, eliminating implicit ordering dependencies between tests

Deprecations

  • SMTPMailHandler.send() returning bool is deprecated (3.0.16-1); will return the smtplib senderrs dict in a future version

Summary by CodeRabbit

Release Notes

  • Bug Fixes

    • Fixed SMTP connection leaks when send operations fail
    • Corrected SMTP timeout parameter handling
    • Resolved SMTP header encoding issues
    • Fixed stale parameter references affecting per-message headers
  • Deprecations

    • SMTPMailHandler.send() returning a boolean is deprecated; future versions will return a dictionary instead
  • Documentation

    • Added GitHub Project and CLI usage instructions
  • Tests

    • Improved test isolation to prevent state pollution between tests
    • Added deprecation warning validation

derks added 7 commits March 9, 2026 15:06
- Fix timeout passed as local_hostname in SMTP/SMTP_SSL constructors
- Fix stale config_item variable in _get_params for per-message headers
- Wrap SMTP connection in try/finally to prevent connection leak
- Move error log behind conditional so successful sends don't log errors
Header charset was incorrectly set to QP when only body_encoding was
'quoted-printable'. Each encoding block now only checks its own setting.
- Rename partText/partHtml to part_text/part_html
- Replace str.strip(x) with x.strip()
- Move TypeError for invalid body type to else clause, removing
  redundant type() check before isinstance chain
Extract _build_charsets, _build_body_parts, _build_mime_structure,
_set_headers, _attach_body, and _attach_files from the monolithic
_make_message method. All are private with no public API change.
Consolidate X-header name normalization in _get_params (underscores to
hyphens) and remove the .title() call in _set_headers, which could
mangle acronyms like X-API-Key into X-Api-Key.
Replace module-level mutable defaults dict with a _get_defaults() helper
that each test calls independently, eliminating implicit ordering
dependencies between tests.
Register deprecation 3.0.16-1 and emit DeprecationWarning on every
send() call. The bool return is preserved for backward compatibility
but will be replaced with the smtplib senderrs dict in a future version.
@coderabbitai
Copy link

coderabbitai bot commented Mar 9, 2026

📝 Walkthrough

Walkthrough

This update adds deprecation warnings for SMTP handler return type changes and includes bug fixes for SMTP connection handling, timeout usage, header encoding, and X-header normalization. Message construction logic is refactored into modular helper methods with test coverage updates.

Changes

Cohort / File(s) Summary
Documentation
CHANGELOG.md, CLAUDE.md
Updated changelog with 3.0.15 entries documenting SMTP fixes and deprecations; added GitHub Project documentation section.
Deprecation Infrastructure
cement/core/deprecations.py
Added new deprecation entry '3.0.16-1' marking SMTPMailHandler.send() bool return as deprecated with planned dict return in future versions.
SMTP Implementation
cement/ext/ext_smtp.py
Major refactor of message construction into focused helper methods (_build_charsets, _build_body_parts, _build_mime_structure, _set_headers, _attach_body, _attach_files); added try/finally block for proper server connection cleanup; fixed timeout parameter usage for both SSL/non-SSL; normalized X-header handling; integrated deprecation warning for bool return path; improved header/param validation with string stripping.
SMTP Tests
tests/ext/test_ext_smtp.py
Extracted test defaults into _get_defaults() helper function to isolate test state; refactored existing tests to use centralized defaults; added test_smtp_send_deprecation_warning() to verify deprecation warning emission.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30 minutes

Poem

🐰 whispers to the warren...
SMTP flows now cleaner still,
With helpers built with careful will,
Connections close with graceful care,
Deprecations marked with flair,
Future versions? They'll prepare! 🌟

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.21% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'refactor(ext.smtp): cleanup ext smtp' is vague and generic. While it references the ext.smtp module, it uses the non-descriptive term 'cleanup' without conveying the actual scope of significant bug fixes, refactoring work, or deprecation changes detailed in the PR objectives. Use a more specific title that captures the primary changes, such as 'refactor(ext.smtp): fix SMTP issues and refactor message handling' or 'refactor(ext.smtp): fix connection leaks, header encoding, and deprecate bool return'.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch refactor/cleanup-ext-smtp

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
cement/ext/ext_smtp.py (1)

385-395: Filenames with special characters may cause issues in Content-Disposition.

The filename is directly interpolated into the header value without RFC 2231 encoding or quoting. Filenames containing spaces, quotes, or non-ASCII characters may result in malformed headers or be misinterpreted by mail clients.

♻️ Consider using email library's parameter encoding
             if cid:
                 part.add_header(
                     'Content-Disposition',
-                    f'inline; filename={altname}',
+                    'inline',
+                    filename=altname,
                 )
                 part.add_header('Content-ID', f'<{cid}>')
             else:
                 part.add_header(
                     'Content-Disposition',
-                    f'attachment; filename={altname}',
+                    'attachment',
+                    filename=altname,
                 )

Using keyword arguments lets add_header handle proper RFC 2231 encoding for special characters.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cement/ext/ext_smtp.py` around lines 385 - 395, The Content-Disposition
header is being built by interpolating altname directly which can break for
spaces, quotes or non-ASCII; update the two add_header calls that set
'Content-Disposition' (in ext_smtp.py where part.add_header is used with
f'inline; filename={altname}' and f'attachment; filename={altname}') to pass the
filename as a keyword parameter (e.g., use
part.add_header('Content-Disposition', 'inline' or 'attachment',
filename=altname) so the email library will perform proper RFC 2231
encoding/quoting for altname), keeping the existing Content-ID handling for cid
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@cement/ext/ext_smtp.py`:
- Around line 231-237: The code that builds MIME parts when body is a dict (the
branch using variables part_text, part_html and calling MIMEText with cs_body)
currently calls body['text'].strip() and body['html'].strip() without null
checks; update those conditions to ensure the dict values are not None (e.g.,
check that body.get('text') is not None and is truthy before calling .strip(),
and similarly for body.get('html')) so you only call .strip() on real strings
and avoid AttributeError when values are None.

---

Nitpick comments:
In `@cement/ext/ext_smtp.py`:
- Around line 385-395: The Content-Disposition header is being built by
interpolating altname directly which can break for spaces, quotes or non-ASCII;
update the two add_header calls that set 'Content-Disposition' (in ext_smtp.py
where part.add_header is used with f'inline; filename={altname}' and
f'attachment; filename={altname}') to pass the filename as a keyword parameter
(e.g., use part.add_header('Content-Disposition', 'inline' or 'attachment',
filename=altname) so the email library will perform proper RFC 2231
encoding/quoting for altname), keeping the existing Content-ID handling for cid
unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 012c85a9-dffc-4722-bc87-98db29dade7e

📥 Commits

Reviewing files that changed from the base of the PR and between 9768476 and 0ebae1f.

📒 Files selected for processing (5)
  • CHANGELOG.md
  • CLAUDE.md
  • cement/core/deprecations.py
  • cement/ext/ext_smtp.py
  • tests/ext/test_ext_smtp.py

Comment on lines 231 to +237
elif isinstance(body, dict):
# handle plain text
if 'text' in body and str.strip(body['text']) != '':
partText = MIMEText(str.strip(body['text']),
'plain',
_charset=cs_body)
# handle html
if 'html' in body and str.strip(body['html']) != '':
partHtml = MIMEText(str.strip(body['html']), 'html', _charset=cs_body)

# To define the correct message content-type
# we need to indentify the content of this mail.
if 'text' in body and body['text'].strip() != '':
part_text = MIMEText(body['text'].strip(),
'plain',
_charset=cs_body)
if 'html' in body and body['html'].strip() != '':
part_html = MIMEText(body['html'].strip(), 'html', _charset=cs_body)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Potential AttributeError if dict values are None.

Lines 232 and 236 call .strip() on dict values without null-checking first. If body['text'] or body['html'] is None, this will raise AttributeError: 'NoneType' object has no attribute 'strip'.

🛡️ Proposed fix to add null checks
         elif isinstance(body, dict):
-            if 'text' in body and body['text'].strip() != '':
+            if 'text' in body and body['text'] and body['text'].strip() != '':
                 part_text = MIMEText(body['text'].strip(),
                                      'plain',
                                      _charset=cs_body)
-            if 'html' in body and body['html'].strip() != '':
+            if 'html' in body and body['html'] and body['html'].strip() != '':
                 part_html = MIMEText(body['html'].strip(), 'html', _charset=cs_body)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@cement/ext/ext_smtp.py` around lines 231 - 237, The code that builds MIME
parts when body is a dict (the branch using variables part_text, part_html and
calling MIMEText with cs_body) currently calls body['text'].strip() and
body['html'].strip() without null checks; update those conditions to ensure the
dict values are not None (e.g., check that body.get('text') is not None and is
truthy before calling .strip(), and similarly for body.get('html')) so you only
call .strip() on real strings and avoid AttributeError when values are None.

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.

1 participant