bulk-email-sender 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Oliveira
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,2 @@
1
+ include README.md
2
+ include LICENSE
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.4
2
+ Name: bulk-email-sender
3
+ Version: 0.2.0
4
+ Summary: A modular, state-persisting mass email sender library using SMTP — supports multi-account rotation, daily limits, and attachments
5
+ Author-email: Jeferson Oliveira Madeira <jeffoliveira977@gmail.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/jeffoliveira977/Bulk-email-sender
8
+ Project-URL: Repository, https://github.com/jeffoliveira977/Bulk-email-sender
9
+ Project-URL: Issues, https://github.com/jeffoliveira977/Bulk-email-sender/issues
10
+ Project-URL: Changelog, https://github.com/jeffoliveira977/Bulk-email-sender/releases
11
+ Keywords: email,smtp,bulk,sender,automation,newsletter,mass-email
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: End Users/Desktop
15
+ Classifier: Topic :: Communications :: Email
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.8
19
+ Classifier: Programming Language :: Python :: 3.9
20
+ Classifier: Programming Language :: Python :: 3.10
21
+ Classifier: Programming Language :: Python :: 3.11
22
+ Classifier: Programming Language :: Python :: 3.12
23
+ Classifier: Operating System :: OS Independent
24
+ Classifier: Natural Language :: English
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Dynamic: license-file
29
+
30
+ # Bulk Email Sender
31
+
32
+ [![PyPI version](https://img.shields.io/pypi/v/bulk-email-sender?color=blue)](https://pypi.org/project/bulk-email-sender/)
33
+ [![Python](https://img.shields.io/pypi/pyversions/bulk-email-sender)](https://pypi.org/project/bulk-email-sender/)
34
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
35
+
36
+ A modular, robust, and state-persisting mass email sender library for Python. It supports multiple sender accounts with round-robin rotation, personalized recipient names, automatic daily safety caps, cooldown intervals between emails, attachments, and automatic daily counter resets.
37
+
38
+ ---
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ # 1. Install
44
+ pip install bulk-email-sender
45
+
46
+ # 2. Initialise workspace — creates a config.json template in the current directory
47
+ bulk-email-sender
48
+
49
+ # 3. Edit config.json with your credentials, leads file, subject, body, etc.
50
+
51
+ # 4. Run the campaign
52
+ bulk-email-sender
53
+
54
+ # 5. (Optional) Reset progress state and start over
55
+ bulk-email-sender --reset
56
+ ```
57
+
58
+ You can also run the package directly as a Python module:
59
+
60
+ ```bash
61
+ python -m bulk_email_sender [--reset]
62
+ ```
63
+
64
+ Or import and drive it programmatically:
65
+
66
+ ```python
67
+ from bulk_email_sender import BulkEmailSender
68
+
69
+ config = {
70
+ "provider": "gmail",
71
+ "senders": [{"email": "you@gmail.com", "password": "app-password", "name": "You"}],
72
+ "leads_path": "leads.txt",
73
+ "subject": "Hello!",
74
+ "body_path": "body.txt",
75
+ "attachment_path": "resume.pdf", # optional
76
+ "interval_seconds": 60,
77
+ "daily_limit": 450,
78
+ }
79
+
80
+ BulkEmailSender(config).run()
81
+ ```
82
+
83
+
84
+
85
+ ## Configuration
86
+
87
+ Configurations are loaded from `config.json` in the root workspace.
88
+
89
+ ### Example `config.json`
90
+
91
+ ```json
92
+ {
93
+ "provider": "gmail",
94
+ "senders": [
95
+ {
96
+ "email": "sender1@gmail.com",
97
+ "password": "app-password-1",
98
+ "name": "Marcos (Account 1)"
99
+ },
100
+ {
101
+ "email": "sender2@yahoo.com",
102
+ "password": "app-password-2",
103
+ "name": "Marcos (Account 2)",
104
+ "provider": "yahoo"
105
+ }
106
+ ],
107
+ "leads_path": "leads.txt",
108
+ "subject": "Your Subject Here",
109
+ "body_path": "body.txt",
110
+ "attachment_path": "document.pdf",
111
+ "interval_seconds": 60,
112
+ "daily_limit": 450
113
+ }
114
+ ```
115
+
116
+ *Note: Environment variables `GMAIL_SENDER` and `GMAIL_APP_PASSWORD` will override `"senders"` in the configuration on execution if they are set.*
117
+
118
+ ---
119
+
120
+ ## Per-Sender Provider Overrides
121
+
122
+ Individually configured senders in the `"senders"` list can override the global campaign provider by defining their own `"provider"`, `"smtp_host"`, or `"smtp_port"` keys.
123
+
124
+ If a sender specifies a provider override, the library automatically closes the active connection and establishes a session with the correct hostname/port for the new provider.
125
+
126
+ ### SMTP Providers Presets
127
+
128
+ Built-in presets automatically configure the host and port depending on your chosen `"provider"`:
129
+
130
+ | Provider Name | Default Host | Port |
131
+ | :--- | :--- | :--- |
132
+ | `"gmail"` | `smtp.gmail.com` | `587` |
133
+ | `"yahoo"` | `smtp.mail.yahoo.com` | `587` |
134
+ | `"icloud"` | `smtp.mail.me.com` | `587` |
135
+ | `"ZOHO"` | `smtp.zoho.com` | `587` |
136
+ | `"GMX"` | `smtp.gmx.com` | `587` |
137
+ | `"YANDEX"` | `smtp.yandex.com` | `587` |
138
+ | `"AOL"` | `smtp.aol.com` | `587` |
139
+ ---
140
+
141
+ ## Round-Robin Account Rotation
142
+
143
+ The library automatically rotates through the configured accounts in `"senders"` round-robin style (1st email from account 1, 2nd email from account 2, 3rd from account 1, etc.).
144
+
145
+ If a new account is chosen in the rotation, the SMTP connection automatically disconnects and re-establishes session credentials to make sending look natural and stay within safety boundaries.
146
+
147
+ ---
148
+
149
+ ## Customizing Email Body
150
+
151
+ The email body is loaded dynamically from the file specified in the `"body_path"` key (default: `body.txt`).
152
+ Any newlines (`\n`) in `body.txt` are automatically converted to HTML line break tags (`<br>`) before sending.
153
+
154
+ ### Example `body.txt`
155
+
156
+ ```text
157
+ I hope this message finds you well.
158
+
159
+ I am writing to express my interest in potential job opportunities in IT. Please find attached my curriculum vitae for your consideration.
160
+
161
+ Thank you for your time and review.
162
+ ```
163
+
164
+ ---
165
+
166
+ ## Email Attachments (Optional)
167
+
168
+ An optional file attachment can be sent with every email by configuring the `"attachment_path"` key in `config.json`.
169
+
170
+ * **Supported Formats:** Any file type (e.g., `.pdf`, `.docx`, `.xlsx`, `.zip`). The sender automatically encodes and attaches it using standard MIME Base64 encoding.
171
+ * **Size Limit Validation:** The program checks the file size before starting the campaign. If the attachment exceeds the **25 MB limit**, the campaign is aborted to prevent failures.
172
+ * **Missing Files:** If the specified file does not exist, the campaign will abort immediately with an error log.
173
+ * **No Attachment:** If you do not want to send an attachment, simply leave `"attachment_path": ""` as an empty string or omit it.
174
+
175
+ ---
176
+
177
+ ## Auto-Generated Files
178
+
179
+ The following files are generated automatically in the execution directory and do not need to be configured:
180
+ * `email_state.json`: Tracks campaign progress, daily counts, and failed indexes. Allows resuming if interrupted.
181
+ * `email_sender.log`: Logs sending statuses, warnings, and errors.
182
+
183
+ ---
184
+
185
+ ## Defining Recipient Leads (TXT or CSV)
186
+
187
+ ### 1. Plain Text Format (`.txt`)
188
+ Name and email separated by a comma (`,`) or semicolon (`;`). The name is optional:
189
+
190
+ ```text
191
+ another-recipient@example.com # Generic greeting: "Hello,"
192
+ Jane Doe; jane.doe@example.com # Personalised greeting: "Hello Jane Doe,"
193
+ ```
194
+
195
+ ### 2. CSV Format (`.csv`)
196
+ Requires an `email` column, and optional `name` column:
197
+
198
+ ```csv
199
+ name,email
200
+ Jane,jane.doe@gmail.com
201
+ ,another-recipient@example.com
202
+ ```
203
+
204
+ If the name is missing, it defaults to a generic `"Hello,"` greeting.
205
+
206
+ ---
@@ -0,0 +1,177 @@
1
+ # Bulk Email Sender
2
+
3
+ [![PyPI version](https://img.shields.io/pypi/v/bulk-email-sender?color=blue)](https://pypi.org/project/bulk-email-sender/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/bulk-email-sender)](https://pypi.org/project/bulk-email-sender/)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
6
+
7
+ A modular, robust, and state-persisting mass email sender library for Python. It supports multiple sender accounts with round-robin rotation, personalized recipient names, automatic daily safety caps, cooldown intervals between emails, attachments, and automatic daily counter resets.
8
+
9
+ ---
10
+
11
+ ## Quick Start
12
+
13
+ ```bash
14
+ # 1. Install
15
+ pip install bulk-email-sender
16
+
17
+ # 2. Initialise workspace — creates a config.json template in the current directory
18
+ bulk-email-sender
19
+
20
+ # 3. Edit config.json with your credentials, leads file, subject, body, etc.
21
+
22
+ # 4. Run the campaign
23
+ bulk-email-sender
24
+
25
+ # 5. (Optional) Reset progress state and start over
26
+ bulk-email-sender --reset
27
+ ```
28
+
29
+ You can also run the package directly as a Python module:
30
+
31
+ ```bash
32
+ python -m bulk_email_sender [--reset]
33
+ ```
34
+
35
+ Or import and drive it programmatically:
36
+
37
+ ```python
38
+ from bulk_email_sender import BulkEmailSender
39
+
40
+ config = {
41
+ "provider": "gmail",
42
+ "senders": [{"email": "you@gmail.com", "password": "app-password", "name": "You"}],
43
+ "leads_path": "leads.txt",
44
+ "subject": "Hello!",
45
+ "body_path": "body.txt",
46
+ "attachment_path": "resume.pdf", # optional
47
+ "interval_seconds": 60,
48
+ "daily_limit": 450,
49
+ }
50
+
51
+ BulkEmailSender(config).run()
52
+ ```
53
+
54
+
55
+
56
+ ## Configuration
57
+
58
+ Configurations are loaded from `config.json` in the root workspace.
59
+
60
+ ### Example `config.json`
61
+
62
+ ```json
63
+ {
64
+ "provider": "gmail",
65
+ "senders": [
66
+ {
67
+ "email": "sender1@gmail.com",
68
+ "password": "app-password-1",
69
+ "name": "Marcos (Account 1)"
70
+ },
71
+ {
72
+ "email": "sender2@yahoo.com",
73
+ "password": "app-password-2",
74
+ "name": "Marcos (Account 2)",
75
+ "provider": "yahoo"
76
+ }
77
+ ],
78
+ "leads_path": "leads.txt",
79
+ "subject": "Your Subject Here",
80
+ "body_path": "body.txt",
81
+ "attachment_path": "document.pdf",
82
+ "interval_seconds": 60,
83
+ "daily_limit": 450
84
+ }
85
+ ```
86
+
87
+ *Note: Environment variables `GMAIL_SENDER` and `GMAIL_APP_PASSWORD` will override `"senders"` in the configuration on execution if they are set.*
88
+
89
+ ---
90
+
91
+ ## Per-Sender Provider Overrides
92
+
93
+ Individually configured senders in the `"senders"` list can override the global campaign provider by defining their own `"provider"`, `"smtp_host"`, or `"smtp_port"` keys.
94
+
95
+ If a sender specifies a provider override, the library automatically closes the active connection and establishes a session with the correct hostname/port for the new provider.
96
+
97
+ ### SMTP Providers Presets
98
+
99
+ Built-in presets automatically configure the host and port depending on your chosen `"provider"`:
100
+
101
+ | Provider Name | Default Host | Port |
102
+ | :--- | :--- | :--- |
103
+ | `"gmail"` | `smtp.gmail.com` | `587` |
104
+ | `"yahoo"` | `smtp.mail.yahoo.com` | `587` |
105
+ | `"icloud"` | `smtp.mail.me.com` | `587` |
106
+ | `"ZOHO"` | `smtp.zoho.com` | `587` |
107
+ | `"GMX"` | `smtp.gmx.com` | `587` |
108
+ | `"YANDEX"` | `smtp.yandex.com` | `587` |
109
+ | `"AOL"` | `smtp.aol.com` | `587` |
110
+ ---
111
+
112
+ ## Round-Robin Account Rotation
113
+
114
+ The library automatically rotates through the configured accounts in `"senders"` round-robin style (1st email from account 1, 2nd email from account 2, 3rd from account 1, etc.).
115
+
116
+ If a new account is chosen in the rotation, the SMTP connection automatically disconnects and re-establishes session credentials to make sending look natural and stay within safety boundaries.
117
+
118
+ ---
119
+
120
+ ## Customizing Email Body
121
+
122
+ The email body is loaded dynamically from the file specified in the `"body_path"` key (default: `body.txt`).
123
+ Any newlines (`\n`) in `body.txt` are automatically converted to HTML line break tags (`<br>`) before sending.
124
+
125
+ ### Example `body.txt`
126
+
127
+ ```text
128
+ I hope this message finds you well.
129
+
130
+ I am writing to express my interest in potential job opportunities in IT. Please find attached my curriculum vitae for your consideration.
131
+
132
+ Thank you for your time and review.
133
+ ```
134
+
135
+ ---
136
+
137
+ ## Email Attachments (Optional)
138
+
139
+ An optional file attachment can be sent with every email by configuring the `"attachment_path"` key in `config.json`.
140
+
141
+ * **Supported Formats:** Any file type (e.g., `.pdf`, `.docx`, `.xlsx`, `.zip`). The sender automatically encodes and attaches it using standard MIME Base64 encoding.
142
+ * **Size Limit Validation:** The program checks the file size before starting the campaign. If the attachment exceeds the **25 MB limit**, the campaign is aborted to prevent failures.
143
+ * **Missing Files:** If the specified file does not exist, the campaign will abort immediately with an error log.
144
+ * **No Attachment:** If you do not want to send an attachment, simply leave `"attachment_path": ""` as an empty string or omit it.
145
+
146
+ ---
147
+
148
+ ## Auto-Generated Files
149
+
150
+ The following files are generated automatically in the execution directory and do not need to be configured:
151
+ * `email_state.json`: Tracks campaign progress, daily counts, and failed indexes. Allows resuming if interrupted.
152
+ * `email_sender.log`: Logs sending statuses, warnings, and errors.
153
+
154
+ ---
155
+
156
+ ## Defining Recipient Leads (TXT or CSV)
157
+
158
+ ### 1. Plain Text Format (`.txt`)
159
+ Name and email separated by a comma (`,`) or semicolon (`;`). The name is optional:
160
+
161
+ ```text
162
+ another-recipient@example.com # Generic greeting: "Hello,"
163
+ Jane Doe; jane.doe@example.com # Personalised greeting: "Hello Jane Doe,"
164
+ ```
165
+
166
+ ### 2. CSV Format (`.csv`)
167
+ Requires an `email` column, and optional `name` column:
168
+
169
+ ```csv
170
+ name,email
171
+ Jane,jane.doe@gmail.com
172
+ ,another-recipient@example.com
173
+ ```
174
+
175
+ If the name is missing, it defaults to a generic `"Hello,"` greeting.
176
+
177
+ ---
@@ -0,0 +1,18 @@
1
+ from .lead import Lead
2
+ from .state import SendState, StateManager
3
+ from .loader import LeadLoader
4
+ from .builder import EmailBuilder
5
+ from .sender import SMTPSender
6
+ from .bulk_sender import BulkEmailSender
7
+ from .provider import SMTPProvider
8
+
9
+ __all__ = [
10
+ "Lead",
11
+ "SendState",
12
+ "StateManager",
13
+ "LeadLoader",
14
+ "EmailBuilder",
15
+ "SMTPSender",
16
+ "BulkEmailSender",
17
+ "SMTPProvider",
18
+ ]
@@ -0,0 +1,127 @@
1
+ """
2
+ bulk_email_sender CLI entrypoint.
3
+
4
+ Can be invoked as:
5
+ python -m bulk_email_sender [--reset]
6
+ bulk-email-sender [--reset]
7
+
8
+ Loads configuration from config.json in the current working directory,
9
+ initialises logging, and runs the BulkEmailSender campaign loop.
10
+ """
11
+
12
+ import os
13
+ import sys
14
+ import json
15
+ import logging
16
+ from pathlib import Path
17
+
18
+ from . import BulkEmailSender
19
+
20
+ # ============================================================
21
+ # LOGGING
22
+ # ============================================================
23
+
24
+ logging.basicConfig(
25
+ level=logging.INFO,
26
+ format="%(asctime)s [%(levelname)s] %(message)s",
27
+ handlers=[
28
+ logging.FileHandler("email_sender.log", encoding="utf-8"),
29
+ logging.StreamHandler(),
30
+ ],
31
+ )
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # ============================================================
36
+ # CONFIGURATION LOADER
37
+ # ============================================================
38
+
39
+ def load_config(config_path: str = "config.json") -> dict:
40
+ """Load campaign configuration from *config_path*.
41
+
42
+ If the file does not exist a default template is created in the current
43
+ working directory and the process exits with code 0 so the user can fill
44
+ in their credentials before re-running.
45
+ """
46
+ path = Path(config_path)
47
+
48
+ if not path.exists():
49
+ default_config = {
50
+ "provider": "gmail",
51
+ "senders": [
52
+ {
53
+ "email": "your-email@gmail.com",
54
+ "password": "your-app-password",
55
+ "name": "Your Name",
56
+ }
57
+ ],
58
+ "leads_path": "leads.txt",
59
+ "subject": "Your Subject",
60
+ "body_path": "body.txt",
61
+ "attachment_path": "",
62
+ "interval_seconds": 60,
63
+ "daily_limit": 450,
64
+ }
65
+ try:
66
+ path.write_text(
67
+ json.dumps(default_config, indent=2, ensure_ascii=False),
68
+ encoding="utf-8",
69
+ )
70
+ logger.info(
71
+ f"Created default configuration template at '{config_path}'. "
72
+ "Please fill in your credentials and re-run."
73
+ )
74
+ except Exception as exc:
75
+ logger.error(f"Failed to create default configuration file: {exc}")
76
+ sys.exit(0)
77
+
78
+ try:
79
+ config = json.loads(path.read_text(encoding="utf-8"))
80
+
81
+ # Allow environment-variable overrides for CI / containerised runs
82
+ env_email = os.environ.get("GMAIL_SENDER")
83
+ env_password = os.environ.get("GMAIL_APP_PASSWORD")
84
+
85
+ if env_email and env_password:
86
+ config["senders"] = [
87
+ {
88
+ "email": env_email,
89
+ "password": env_password,
90
+ "name": config.get("senders", [{}])[0].get("name") or "Sender",
91
+ }
92
+ ]
93
+
94
+ return config
95
+
96
+ except Exception as exc:
97
+ logger.error(f"Failed to load configuration file '{config_path}': {exc}")
98
+ sys.exit(1)
99
+
100
+
101
+ # ============================================================
102
+ # MAIN ENTRYPOINT
103
+ # ============================================================
104
+
105
+ def main() -> None:
106
+ """Entry point registered in pyproject.toml console_scripts."""
107
+ config = load_config()
108
+
109
+ # Reset progress state when --reset flag is passed
110
+ if "--reset" in sys.argv:
111
+ state_file = config.get("state_file_path") or "email_state.json"
112
+ state_path = Path(state_file)
113
+ if state_path.exists():
114
+ try:
115
+ state_path.unlink()
116
+ logger.info(f"Progress state '{state_file}' has been reset.")
117
+ except Exception as exc:
118
+ logger.error(f"Failed to reset state file: {exc}")
119
+ else:
120
+ logger.info("No active progress state found to reset.")
121
+
122
+ sender = BulkEmailSender(config)
123
+ sender.run()
124
+
125
+
126
+ if __name__ == "__main__":
127
+ main()
@@ -0,0 +1,114 @@
1
+ import logging
2
+ from pathlib import Path
3
+ from email.mime.multipart import MIMEMultipart
4
+ from email.mime.text import MIMEText
5
+ from email.mime.base import MIMEBase
6
+ from email import encoders
7
+ from .lead import Lead
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ class EmailBuilder:
12
+ """Builds standard/multipart email messages based on a hardcoded template."""
13
+
14
+ TEMPLATE = """\
15
+ <html>
16
+ <head>
17
+ <style>
18
+ body {{
19
+ font-family: Arial, sans-serif;
20
+ font-size: 14px;
21
+ line-height: 1.6;
22
+ color: #333333;
23
+ }}
24
+ .signature {{
25
+ margin-top: 20px;
26
+ }}
27
+ </style>
28
+ </head>
29
+ <body>
30
+ {greeting_part}
31
+
32
+ <div>
33
+ {body_text}
34
+ </div>
35
+
36
+ <div class="signature">
37
+ <p>Best regards,<br>
38
+ <strong>{sender_name}</strong></p>
39
+ </div>
40
+ </body>
41
+ </html>
42
+ """
43
+
44
+ def __init__(self, config: dict):
45
+ self.config = config
46
+ self.subject = config.get("subject", "No Subject")
47
+ self.sender_name = config.get("sender_name", "")
48
+
49
+ attachment_path = config.get("attachment_path")
50
+ if attachment_path and str(attachment_path).strip():
51
+ self.attachment_path = str(attachment_path).strip()
52
+ else:
53
+ self.attachment_path = None
54
+
55
+ body_text = config.get("body_text", "")
56
+ body_path = config.get("body_path")
57
+ if body_path:
58
+ p = Path(body_path)
59
+ if p.exists():
60
+ body_text = p.read_text(encoding="utf-8")
61
+ else:
62
+ logger.warning(f"Body file not found at: {body_path}. Using default body text.")
63
+ self.body_text = body_text
64
+
65
+ def build(self, lead: Lead, sender_credentials: dict) -> MIMEMultipart:
66
+ msg = MIMEMultipart("mixed")
67
+ msg["Subject"] = self.subject
68
+
69
+ sender_name = sender_credentials.get("name") or self.sender_name
70
+ sender_email = sender_credentials.get("email", "")
71
+
72
+ msg["From"] = f"{sender_name} <{sender_email}>"
73
+ msg["To"] = lead.email
74
+
75
+ if lead.name:
76
+ recipient_name = lead.name.strip().title()
77
+ greeting_part = f"<p>Hello {recipient_name},</p>"
78
+ else:
79
+ greeting_part = "<p>Hello,</p>"
80
+
81
+ body_html = self.body_text
82
+ if "<p>" not in body_html and "<br>" not in body_html:
83
+ body_html = body_html.replace("\n", "<br>\n")
84
+
85
+ html_content = self.TEMPLATE.format(
86
+ greeting_part=greeting_part,
87
+ body_text=body_html,
88
+ sender_name=sender_name
89
+ )
90
+
91
+ body_part = MIMEMultipart("alternative")
92
+ body_part.attach(MIMEText(html_content, "html", "utf-8"))
93
+ msg.attach(body_part)
94
+
95
+ if self.attachment_path:
96
+ self._attach_file(msg)
97
+
98
+ return msg
99
+
100
+ def _attach_file(self, msg: MIMEMultipart) -> None:
101
+ if not self.attachment_path:
102
+ return
103
+
104
+ p = Path(self.attachment_path)
105
+ if not p.exists():
106
+ raise FileNotFoundError(f"Attachment not found: {self.attachment_path}")
107
+
108
+ with open(p, "rb") as f:
109
+ part = MIMEBase("application", "octet-stream")
110
+ part.set_payload(f.read())
111
+
112
+ encoders.encode_base64(part)
113
+ part.add_header("Content-Disposition", f'attachment; filename="{p.name}"')
114
+ msg.attach(part)