whahn1983 /
pycashflow
| 1 | #!/usr/bin/env python3 |
||
| 2 | """ |
||
| 3 | Email balance import script for pycashflow. |
||
| 4 | Processes IMAP email inboxes to extract and import balance information. |
||
| 5 | |||
| 6 | This script can be run standalone from cron or called from within the application. |
||
| 7 | """ |
||
| 8 | import os |
||
| 9 | import sys |
||
| 10 | |||
| 11 | # When run as standalone script, add parent directory to path for imports |
||
| 12 | # This allows 'from app import ...' to work when called from cron |
||
| 13 | parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) |
||
| 14 | if parent_dir not in sys.path: |
||
| 15 | sys.path.insert(0, parent_dir) |
||
| 16 | |||
| 17 | import imaplib |
||
| 18 | import email |
||
| 19 | from email.header import decode_header |
||
| 20 | from email.mime.text import MIMEText |
||
| 21 | from email.mime.multipart import MIMEMultipart |
||
| 22 | import smtplib |
||
| 23 | from datetime import datetime, timedelta |
||
| 24 | from app import db |
||
| 25 | from app.models import User, Email, Balance, GlobalEmailSettings |
||
| 26 | |||
| 27 | |||
| 28 | def process_email_balances(): |
||
| 29 | """ |
||
| 30 | Process email inboxes for all users with email settings configured |
||
| 31 | and update their balance information from bank emails. |
||
| 32 | |||
| 33 | This function supports both SQLite and PostgreSQL through SQLAlchemy. |
||
| 34 | """ |
||
| 35 | # OUTER LOOP: Get all users with email settings configured |
||
| 36 | users_with_email = db.session.query(User, Email).join( |
||
| 37 | Email, User.id == Email.user_id |
||
| 38 | ).all() |
||
| 39 | |||
| 40 | for user, email_config in users_with_email: |
||
| 41 | user_id = user.id |
||
| 42 | username = email_config.email |
||
| 43 | password = email_config.password |
||
| 44 | imap_server = email_config.server |
||
| 45 | subjectstr = email_config.subjectstr |
||
| 46 | startstr = email_config.startstr |
||
| 47 | endstr = email_config.endstr |
||
| 48 | |||
| 49 | try: |
||
| 50 | # Create IMAP connection for THIS user |
||
| 51 | imap = imaplib.IMAP4_SSL(imap_server) |
||
| 52 | imap.login(username, password) |
||
| 53 | |||
| 54 | status, messages = imap.select("INBOX", readonly=True) |
||
| 55 | |||
| 56 | # Filter for emails from the past day to limit inbox processing |
||
| 57 | # IMAP SINCE searches for messages with Date on or after the specified date |
||
| 58 | yesterday = (datetime.now() - timedelta(days=1)).strftime("%d-%b-%Y") |
||
| 59 | status, message_ids = imap.search(None, f'SINCE {yesterday}') |
||
| 60 | |||
| 61 | # Parse message IDs from the search result |
||
| 62 | if message_ids[0]: |
||
| 63 | email_ids = message_ids[0].split() |
||
| 64 | else: |
||
| 65 | email_ids = [] |
||
| 66 | |||
| 67 | print(f"Found {len(email_ids)} email(s) from the past day for user {user_id}") |
||
| 68 | |||
| 69 | email_content = {} |
||
| 70 | |||
| 71 | # INNER LOOP: Process only emails from the past day |
||
| 72 | for email_id in email_ids: |
||
| 73 | try: |
||
| 74 | res, msg = imap.fetch(email_id, "(RFC822)") |
||
| 75 | for response in msg: |
||
| 76 | if isinstance(response, tuple): |
||
| 77 | msg = email.message_from_bytes(response[1]) |
||
| 78 | |||
| 79 | # Decode subject |
||
| 80 | subject, encoding = decode_header(msg["Subject"])[0] |
||
| 81 | if isinstance(subject, bytes): |
||
| 82 | try: |
||
| 83 | subject = subject.decode(encoding) |
||
| 84 | except: |
||
| 85 | subject = "subject" |
||
| 86 | |||
| 87 | # Decode sender |
||
| 88 | From, encoding = decode_header(msg.get("From"))[0] |
||
| 89 | if isinstance(From, bytes): |
||
| 90 | try: |
||
| 91 | From = From.decode(encoding) |
||
| 92 | except: |
||
| 93 | pass |
||
| 94 | |||
| 95 | # Extract email body |
||
| 96 | if msg.is_multipart(): |
||
| 97 | for part in msg.walk(): |
||
| 98 | content_type = part.get_content_type() |
||
| 99 | content_disposition = str(part.get("Content-Disposition")) |
||
| 100 | try: |
||
| 101 | body = part.get_payload(decode=True).decode() |
||
| 102 | except: |
||
| 103 | pass |
||
| 104 | if content_type == "text/plain" and "attachment" not in content_disposition: |
||
| 105 | try: |
||
| 106 | email_content[subject] = body |
||
| 107 | except: |
||
| 108 | pass |
||
| 109 | else: |
||
| 110 | content_type = msg.get_content_type() |
||
| 111 | body = msg.get_payload(decode=True).decode() |
||
| 112 | if content_type == "text/plain": |
||
| 113 | try: |
||
| 114 | email_content[subject] = body |
||
| 115 | except: |
||
| 116 | pass |
||
| 117 | except: |
||
| 118 | pass # Skip individual email errors |
||
| 119 | |||
| 120 | # Extract balance from emails for THIS user |
||
| 121 | try: |
||
| 122 | start_index = email_content[subjectstr].find(startstr) + len(startstr) |
||
| 123 | end_index = email_content[subjectstr].find(endstr) |
||
| 124 | new_balance = email_content[subjectstr][start_index:end_index].replace(',', '') |
||
| 125 | new_balance = new_balance.replace('$', '') |
||
| 126 | new_balance = float(new_balance) |
||
| 127 | |||
| 128 | # Insert balance WITH user_id using SQLAlchemy ORM |
||
| 129 | balance = Balance( |
||
| 130 | amount=new_balance, |
||
| 131 | date=datetime.today().date(), |
||
| 132 | user_id=user_id |
||
| 133 | ) |
||
| 134 | db.session.add(balance) |
||
| 135 | db.session.commit() |
||
| 136 | print(f"Successfully imported balance ${new_balance} for user {user_id}") |
||
| 137 | except KeyError: |
||
| 138 | # No email with the specified subject found |
||
| 139 | pass |
||
| 140 | except Exception as e: |
||
| 141 | # No balance found in emails for this user |
||
| 142 | print(f"Could not extract balance for user {user_id}: {e}") |
||
| 143 | |||
| 144 | # Close IMAP connection for THIS user |
||
| 145 | imap.close() |
||
| 146 | imap.logout() |
||
| 147 | |||
| 148 | except Exception as e: |
||
| 149 | # Failed to connect to IMAP for this user - skip to next user |
||
| 150 | print(f"Failed to process emails for user {user_id}: {e}") |
||
| 151 | continue |
||
| 152 | |||
| 153 | |||
| 154 | View Code Duplication | def send_new_user_notification(new_user_name, new_user_email): |
|
|
0 ignored issues
–
show
Duplication
introduced
by
Loading history...
|
|||
| 155 | """ |
||
| 156 | Send an email notification to the global admin when a new user registers. |
||
| 157 | |||
| 158 | Args: |
||
| 159 | new_user_name: Name of the newly registered user |
||
| 160 | new_user_email: Email of the newly registered user |
||
| 161 | |||
| 162 | Returns: |
||
| 163 | bool: True if email was sent successfully, False otherwise |
||
| 164 | """ |
||
| 165 | try: |
||
| 166 | # Get the global admin user |
||
| 167 | global_admin = User.query.filter_by(is_global_admin=True).first() |
||
| 168 | |||
| 169 | if not global_admin: |
||
| 170 | print("No global admin found, skipping notification") |
||
| 171 | return False |
||
| 172 | |||
| 173 | # Get global email settings |
||
| 174 | email_settings = GlobalEmailSettings.query.first() |
||
| 175 | |||
| 176 | if not email_settings: |
||
| 177 | print("No global email settings configured, skipping notification") |
||
| 178 | return False |
||
| 179 | |||
| 180 | # Extract email credentials from global settings |
||
| 181 | from_email = email_settings.email |
||
| 182 | password = email_settings.password |
||
| 183 | smtp_server = email_settings.smtp_server |
||
| 184 | |||
| 185 | # Create the email message |
||
| 186 | msg = MIMEMultipart('alternative') |
||
| 187 | msg['From'] = from_email |
||
| 188 | msg['To'] = global_admin.email # Send to the global admin's email |
||
| 189 | msg['Subject'] = 'New User Registration - PyCashFlow' |
||
| 190 | |||
| 191 | # Create plain text version |
||
| 192 | text_content = f""" |
||
| 193 | A new user has registered on PyCashFlow. |
||
| 194 | |||
| 195 | Name: {new_user_name} |
||
| 196 | Email: {new_user_email} |
||
| 197 | Registration Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} |
||
| 198 | |||
| 199 | This user account is currently inactive and requires approval before they can log in. |
||
| 200 | Please log in to your PyCashFlow account to activate this user. |
||
| 201 | """ |
||
| 202 | |||
| 203 | # Create HTML version |
||
| 204 | html_content = f""" |
||
| 205 | <html> |
||
| 206 | <body> |
||
| 207 | <h2>New User Registration</h2> |
||
| 208 | <p>A new user has registered on PyCashFlow.</p> |
||
| 209 | <table style="border-collapse: collapse; margin-top: 10px;"> |
||
| 210 | <tr> |
||
| 211 | <td style="padding: 5px; font-weight: bold;">Name:</td> |
||
| 212 | <td style="padding: 5px;">{new_user_name}</td> |
||
| 213 | </tr> |
||
| 214 | <tr> |
||
| 215 | <td style="padding: 5px; font-weight: bold;">Email:</td> |
||
| 216 | <td style="padding: 5px;">{new_user_email}</td> |
||
| 217 | </tr> |
||
| 218 | <tr> |
||
| 219 | <td style="padding: 5px; font-weight: bold;">Registration Date:</td> |
||
| 220 | <td style="padding: 5px;">{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</td> |
||
| 221 | </tr> |
||
| 222 | </table> |
||
| 223 | <p style="margin-top: 15px;"> |
||
| 224 | <strong>Note:</strong> This user account is currently inactive and requires approval before they can log in. |
||
| 225 | Please log in to your PyCashFlow account to activate this user. |
||
| 226 | </p> |
||
| 227 | </body> |
||
| 228 | </html> |
||
| 229 | """ |
||
| 230 | |||
| 231 | # Attach both versions |
||
| 232 | part1 = MIMEText(text_content, 'plain') |
||
| 233 | part2 = MIMEText(html_content, 'html') |
||
| 234 | msg.attach(part1) |
||
| 235 | msg.attach(part2) |
||
| 236 | |||
| 237 | # Send the email using SMTP |
||
| 238 | # Try port 587 with STARTTLS first (most common) |
||
| 239 | try: |
||
| 240 | server = smtplib.SMTP(smtp_server, 587, timeout=10) |
||
| 241 | server.starttls() |
||
| 242 | server.login(from_email, password) |
||
| 243 | server.sendmail(from_email, global_admin.email, msg.as_string()) |
||
| 244 | server.quit() |
||
| 245 | print(f"Successfully sent new user notification email to {global_admin.email}") |
||
| 246 | return True |
||
| 247 | except Exception as e: |
||
| 248 | # If port 587 fails, try port 465 with SSL |
||
| 249 | print(f"Failed to send via port 587, trying SSL on port 465: {e}") |
||
| 250 | try: |
||
| 251 | server = smtplib.SMTP_SSL(smtp_server, 465, timeout=10) |
||
| 252 | server.login(from_email, password) |
||
| 253 | server.sendmail(from_email, global_admin.email, msg.as_string()) |
||
| 254 | server.quit() |
||
| 255 | print(f"Successfully sent new user notification email to {global_admin.email} via SSL") |
||
| 256 | return True |
||
| 257 | except Exception as e2: |
||
| 258 | print(f"Failed to send email via both ports: {e2}") |
||
| 259 | return False |
||
| 260 | |||
| 261 | except Exception as e: |
||
| 262 | print(f"Error sending new user notification: {e}") |
||
| 263 | return False |
||
| 264 | |||
| 265 | |||
| 266 | View Code Duplication | def send_account_activation_notification(user_name, user_email): |
|
|
0 ignored issues
–
show
|
|||
| 267 | """ |
||
| 268 | Send an email notification to a user when their account is activated. |
||
| 269 | |||
| 270 | Args: |
||
| 271 | user_name: Name of the user whose account was activated |
||
| 272 | user_email: Email of the user whose account was activated |
||
| 273 | |||
| 274 | Returns: |
||
| 275 | bool: True if email was sent successfully, False otherwise |
||
| 276 | """ |
||
| 277 | try: |
||
| 278 | # Get global email settings |
||
| 279 | email_settings = GlobalEmailSettings.query.first() |
||
| 280 | |||
| 281 | if not email_settings: |
||
| 282 | print("No global email settings configured, skipping activation notification") |
||
| 283 | return False |
||
| 284 | |||
| 285 | # Extract email credentials from global settings |
||
| 286 | from_email = email_settings.email |
||
| 287 | password = email_settings.password |
||
| 288 | smtp_server = email_settings.smtp_server |
||
| 289 | |||
| 290 | # Create the email message |
||
| 291 | msg = MIMEMultipart('alternative') |
||
| 292 | msg['From'] = from_email |
||
| 293 | msg['To'] = user_email |
||
| 294 | msg['Subject'] = 'Your PyCashFlow Account Has Been Activated' |
||
| 295 | |||
| 296 | # Create plain text version |
||
| 297 | text_content = f""" |
||
| 298 | Hello {user_name}, |
||
| 299 | |||
| 300 | Great news! Your PyCashFlow account has been activated and you can now log in. |
||
| 301 | |||
| 302 | You can access your account at any time by visiting the login page. |
||
| 303 | |||
| 304 | If you have any questions or need assistance, please contact your administrator. |
||
| 305 | |||
| 306 | Best regards, |
||
| 307 | The PyCashFlow Team |
||
| 308 | """ |
||
| 309 | |||
| 310 | # Create HTML version |
||
| 311 | html_content = f""" |
||
| 312 | <html> |
||
| 313 | <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"> |
||
| 314 | <div style="max-width: 600px; margin: 0 auto; padding: 20px;"> |
||
| 315 | <h2 style="color: #10b981; border-bottom: 3px solid #10b981; padding-bottom: 10px;"> |
||
| 316 | Account Activated! |
||
| 317 | </h2> |
||
| 318 | <p>Hello <strong>{user_name}</strong>,</p> |
||
| 319 | <p>Great news! Your PyCashFlow account has been activated and you can now log in.</p> |
||
| 320 | |||
| 321 | <div style="background-color: #f0fdf4; border-left: 4px solid #10b981; padding: 15px; margin: 20px 0;"> |
||
| 322 | <p style="margin: 0;"> |
||
| 323 | <strong>What's next?</strong><br> |
||
| 324 | You can now access all features of PyCashFlow including: |
||
| 325 | </p> |
||
| 326 | <ul style="margin: 10px 0;"> |
||
| 327 | <li>Cash flow forecasting</li> |
||
| 328 | <li>Schedule management</li> |
||
| 329 | <li>Balance tracking</li> |
||
| 330 | <li>And more!</li> |
||
| 331 | </ul> |
||
| 332 | </div> |
||
| 333 | |||
| 334 | <p>If you have any questions or need assistance, please contact your administrator.</p> |
||
| 335 | |||
| 336 | <p style="margin-top: 30px;"> |
||
| 337 | Best regards,<br> |
||
| 338 | <strong>The PyCashFlow Team</strong> |
||
| 339 | </p> |
||
| 340 | </div> |
||
| 341 | </body> |
||
| 342 | </html> |
||
| 343 | """ |
||
| 344 | |||
| 345 | # Attach both versions |
||
| 346 | part1 = MIMEText(text_content, 'plain') |
||
| 347 | part2 = MIMEText(html_content, 'html') |
||
| 348 | msg.attach(part1) |
||
| 349 | msg.attach(part2) |
||
| 350 | |||
| 351 | # Send the email using SMTP |
||
| 352 | # Try port 587 with STARTTLS first (most common) |
||
| 353 | try: |
||
| 354 | server = smtplib.SMTP(smtp_server, 587, timeout=10) |
||
| 355 | server.starttls() |
||
| 356 | server.login(from_email, password) |
||
| 357 | server.sendmail(from_email, user_email, msg.as_string()) |
||
| 358 | server.quit() |
||
| 359 | print(f"Successfully sent account activation email to {user_email}") |
||
| 360 | return True |
||
| 361 | except Exception as e: |
||
| 362 | # If port 587 fails, try port 465 with SSL |
||
| 363 | print(f"Failed to send via port 587, trying SSL on port 465: {e}") |
||
| 364 | try: |
||
| 365 | server = smtplib.SMTP_SSL(smtp_server, 465, timeout=10) |
||
| 366 | server.login(from_email, password) |
||
| 367 | server.sendmail(from_email, user_email, msg.as_string()) |
||
| 368 | server.quit() |
||
| 369 | print(f"Successfully sent account activation email to {user_email} via SSL") |
||
| 370 | return True |
||
| 371 | except Exception as e2: |
||
| 372 | print(f"Failed to send email via both ports: {e2}") |
||
| 373 | return False |
||
| 374 | |||
| 375 | except Exception as e: |
||
| 376 | print(f"Error sending account activation notification: {e}") |
||
| 377 | return False |
||
| 378 | |||
| 379 | |||
| 380 | # Allow script to be run standalone from cron |
||
| 381 | if __name__ == "__main__": |
||
| 382 | from app import create_app |
||
| 383 | |||
| 384 | print("Starting email balance import...") |
||
| 385 | app = create_app() |
||
| 386 | |||
| 387 | with app.app_context(): |
||
| 388 | try: |
||
| 389 | process_email_balances() |
||
| 390 | print("Email balance import completed successfully") |
||
| 391 | except Exception as e: |
||
| 392 | print(f"Email balance import failed: {e}") |
||
| 393 | sys.exit(1) |
||
| 394 |