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.
- bulk_email_sender-0.2.0/LICENSE +21 -0
- bulk_email_sender-0.2.0/MANIFEST.in +2 -0
- bulk_email_sender-0.2.0/PKG-INFO +206 -0
- bulk_email_sender-0.2.0/README.md +177 -0
- bulk_email_sender-0.2.0/bulk_email_sender/__init__.py +18 -0
- bulk_email_sender-0.2.0/bulk_email_sender/__main__.py +127 -0
- bulk_email_sender-0.2.0/bulk_email_sender/builder.py +114 -0
- bulk_email_sender-0.2.0/bulk_email_sender/bulk_sender.py +121 -0
- bulk_email_sender-0.2.0/bulk_email_sender/lead.py +7 -0
- bulk_email_sender-0.2.0/bulk_email_sender/loader.py +107 -0
- bulk_email_sender-0.2.0/bulk_email_sender/provider.py +32 -0
- bulk_email_sender-0.2.0/bulk_email_sender/sender.py +96 -0
- bulk_email_sender-0.2.0/bulk_email_sender/state.py +46 -0
- bulk_email_sender-0.2.0/bulk_email_sender.egg-info/PKG-INFO +206 -0
- bulk_email_sender-0.2.0/bulk_email_sender.egg-info/SOURCES.txt +18 -0
- bulk_email_sender-0.2.0/bulk_email_sender.egg-info/dependency_links.txt +1 -0
- bulk_email_sender-0.2.0/bulk_email_sender.egg-info/entry_points.txt +2 -0
- bulk_email_sender-0.2.0/bulk_email_sender.egg-info/top_level.txt +1 -0
- bulk_email_sender-0.2.0/pyproject.toml +44 -0
- bulk_email_sender-0.2.0/setup.cfg +4 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/bulk-email-sender/)
|
|
33
|
+
[](https://pypi.org/project/bulk-email-sender/)
|
|
34
|
+
[](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
|
+
[](https://pypi.org/project/bulk-email-sender/)
|
|
4
|
+
[](https://pypi.org/project/bulk-email-sender/)
|
|
5
|
+
[](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)
|