medicafe 0.251027.2__tar.gz → 0.251027.3__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.

Potentially problematic release.


This version of medicafe might be problematic. Click here for more details.

Files changed (85) hide show
  1. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/__init__.py +1 -1
  2. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/__init__.py +1 -1
  3. medicafe-0.251027.3/MediCafe/error_reporter.py +603 -0
  4. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Gmail.py +65 -0
  5. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/__init__.py +1 -1
  6. {medicafe-0.251027.2/medicafe.egg-info → medicafe-0.251027.3}/PKG-INFO +1 -1
  7. {medicafe-0.251027.2 → medicafe-0.251027.3/medicafe.egg-info}/PKG-INFO +1 -1
  8. {medicafe-0.251027.2 → medicafe-0.251027.3}/setup.py +1 -1
  9. medicafe-0.251027.2/MediCafe/error_reporter.py +0 -452
  10. {medicafe-0.251027.2 → medicafe-0.251027.3}/LICENSE +0 -0
  11. {medicafe-0.251027.2 → medicafe-0.251027.3}/MANIFEST.in +0 -0
  12. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot.bat +0 -0
  13. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot.py +0 -0
  14. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Charges.py +0 -0
  15. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Crosswalk_Library.py +0 -0
  16. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Crosswalk_Utils.py +0 -0
  17. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Notepad_Utils.py +0 -0
  18. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Post.py +0 -0
  19. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Preprocessor.py +0 -0
  20. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_Preprocessor_lib.py +0 -0
  21. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_UI.py +0 -0
  22. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_dataformat_library.py +0 -0
  23. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_debug.bat +0 -0
  24. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_docx_decoder.py +0 -0
  25. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/MediBot_smart_import.py +0 -0
  26. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/clear_cache.bat +0 -0
  27. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/crash_diagnostic.bat +0 -0
  28. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/f_drive_diagnostic.bat +0 -0
  29. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/full_debug_suite.bat +0 -0
  30. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/get_medicafe_version.py +0 -0
  31. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/process_csvs.bat +0 -0
  32. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/update_json.py +0 -0
  33. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediBot/update_medicafe.py +0 -0
  34. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/MediLink_ConfigLoader.py +0 -0
  35. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/__main__.py +0 -0
  36. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/api_core.py +0 -0
  37. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/api_factory.py +0 -0
  38. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/api_utils.py +0 -0
  39. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/core_utils.py +0 -0
  40. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/deductible_utils.py +0 -0
  41. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/graphql_utils.py +0 -0
  42. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/logging_config.py +0 -0
  43. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/logging_demo.py +0 -0
  44. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/migration_helpers.py +0 -0
  45. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/smart_import.py +0 -0
  46. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediCafe/submission_index.py +0 -0
  47. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/InsuranceTypeService.py +0 -0
  48. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_837p_cob_library.py +0 -0
  49. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_837p_encoder.py +0 -0
  50. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_837p_encoder_library.py +0 -0
  51. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_837p_utilities.py +0 -0
  52. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_API_Generator.py +0 -0
  53. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Azure.py +0 -0
  54. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Charges.py +0 -0
  55. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_ClaimStatus.py +0 -0
  56. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_DataMgmt.py +0 -0
  57. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Decoder.py +0 -0
  58. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Deductible.py +0 -0
  59. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Deductible_Validator.py +0 -0
  60. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Display_Utils.py +0 -0
  61. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Down.py +0 -0
  62. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Mailer.py +0 -0
  63. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Parser.py +0 -0
  64. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_PatientProcessor.py +0 -0
  65. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Scan.py +0 -0
  66. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Scheduler.py +0 -0
  67. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_UI.py +0 -0
  68. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_Up.py +0 -0
  69. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_insurance_utils.py +0 -0
  70. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_main.py +0 -0
  71. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/MediLink_smart_import.py +0 -0
  72. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/Soumit_api.py +0 -0
  73. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/gmail_http_utils.py +0 -0
  74. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/gmail_oauth_utils.py +0 -0
  75. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/openssl.cnf +0 -0
  76. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/test.py +0 -0
  77. {medicafe-0.251027.2 → medicafe-0.251027.3}/MediLink/webapp.html +0 -0
  78. {medicafe-0.251027.2 → medicafe-0.251027.3}/README.md +0 -0
  79. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/SOURCES.txt +0 -0
  80. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/dependency_links.txt +0 -0
  81. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/entry_points.txt +0 -0
  82. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/not-zip-safe +0 -0
  83. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/requires.txt +0 -0
  84. {medicafe-0.251027.2 → medicafe-0.251027.3}/medicafe.egg-info/top_level.txt +0 -0
  85. {medicafe-0.251027.2 → medicafe-0.251027.3}/setup.cfg +0 -0
@@ -19,7 +19,7 @@ Smart Import Integration:
19
19
  medibot_main = get_components('medibot_main')
20
20
  """
21
21
 
22
- __version__ = "0.251027.2"
22
+ __version__ = "0.251027.3"
23
23
  __author__ = "Daniel Vidaud"
24
24
  __email__ = "daniel@personalizedtransformation.com"
25
25
 
@@ -27,7 +27,7 @@ Smart Import System:
27
27
  api_suite = get_api_access()
28
28
  """
29
29
 
30
- __version__ = "0.251027.2"
30
+ __version__ = "0.251027.3"
31
31
  __author__ = "Daniel Vidaud"
32
32
  __email__ = "daniel@personalizedtransformation.com"
33
33
 
@@ -0,0 +1,603 @@
1
+ import base64
2
+ import json
3
+ import os
4
+ import platform
5
+ import sys
6
+ import time
7
+ import zipfile
8
+
9
+ import requests
10
+ import traceback
11
+
12
+ from email.mime.application import MIMEApplication
13
+ from email.mime.multipart import MIMEMultipart
14
+ from email.mime.text import MIMEText
15
+
16
+ from MediCafe.MediLink_ConfigLoader import load_configuration, log as mc_log
17
+ from MediLink.MediLink_Gmail import get_access_token
18
+
19
+
20
+ def _safe_ascii(text):
21
+ try:
22
+ if text is None:
23
+ return ''
24
+ if isinstance(text, bytes):
25
+ try:
26
+ text = text.decode('ascii', 'ignore')
27
+ except Exception:
28
+ text = text.decode('utf-8', 'ignore')
29
+ else:
30
+ text = str(text)
31
+ return text.encode('ascii', 'ignore').decode('ascii', 'ignore')
32
+ except Exception:
33
+ return ''
34
+
35
+
36
+ def _tail_file(path, max_lines):
37
+ lines = []
38
+ try:
39
+ with open(path, 'r') as f:
40
+ for line in f:
41
+ lines.append(line)
42
+ if len(lines) > max_lines:
43
+ lines.pop(0)
44
+ return ''.join(lines)
45
+ except Exception:
46
+ return ''
47
+
48
+
49
+ def _get_latest_log_path(local_storage_path):
50
+ try:
51
+ files = []
52
+ for name in os.listdir(local_storage_path or '.'):
53
+ if name.startswith('Log_') and name.endswith('.log'):
54
+ files.append(os.path.join(local_storage_path, name))
55
+ if not files:
56
+ return None
57
+ files.sort(key=lambda p: os.path.getmtime(p))
58
+ return files[-1]
59
+ except Exception:
60
+ return None
61
+
62
+
63
+ def _redact(text):
64
+ # Best-effort ASCII redaction: mask common secrets in logs and JSON
65
+ try:
66
+ text = _safe_ascii(text)
67
+ import re
68
+ patterns = [
69
+ # SSN-like
70
+ (r'\b(\d{3}-?\d{2}-?\d{4})\b', '***-**-****'),
71
+ # 9-11 digit numeric IDs
72
+ (r'\b(\d{9,11})\b', '*********'),
73
+ # Authorization headers
74
+ (r'Authorization:\s*Bearer\s+[A-Za-z0-9\-._~+/]+=*', 'Authorization: Bearer ***'),
75
+ (r'Authorization:\s*[^\n\r]+', 'Authorization: ***'),
76
+ # JSON token fields
77
+ (r'("access_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
78
+ (r'("refresh_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
79
+ (r'("id_token"\s*:\s*")([^"]+)(")', r'\1***\3'),
80
+ (r'("X-Auth-Token"\s*:\s*")([^"]+)(")', r'\1***\3'),
81
+ # URL query params: token=..., access_token=..., auth=...
82
+ (r'(token|access_token|auth|authorization)=([^&\s]+)', r'\1=***'),
83
+ # Bearer fragments in JSON or text
84
+ (r'Bearer\s+[A-Za-z0-9\-._~+/]+=*', 'Bearer ***'),
85
+ ]
86
+ for pat, rep in patterns:
87
+ text = re.sub(pat, rep, text)
88
+ return text
89
+ except Exception:
90
+ return text
91
+
92
+
93
+ def _ensure_dir(path):
94
+ try:
95
+ if not os.path.exists(path):
96
+ os.makedirs(path)
97
+ return True
98
+ except Exception:
99
+ return False
100
+
101
+
102
+ # Resolve a writable queue directory with fallback to ./reports_queue
103
+ def _resolve_queue_dir(medi_config):
104
+ try:
105
+ local_storage_path = medi_config.get('local_storage_path', '.') if isinstance(medi_config, dict) else '.'
106
+ except Exception:
107
+ local_storage_path = '.'
108
+ primary = os.path.join(local_storage_path, 'reports_queue')
109
+ if _ensure_dir(primary):
110
+ return primary
111
+ fallback = os.path.join('.', 'reports_queue')
112
+ if _ensure_dir(fallback):
113
+ try:
114
+ mc_log("Queue directory fallback to ./reports_queue due to path/permission issue.", level="WARNING")
115
+ print("Falling back to ./reports_queue for support bundles (check local_storage_path permissions).")
116
+ except Exception:
117
+ pass
118
+ return fallback
119
+ return primary
120
+
121
+
122
+ def _build_support_zip(zip_path, local_storage_path, max_log_lines, traceback_text, include_winscp, meta):
123
+ latest_log = _get_latest_log_path(local_storage_path)
124
+ log_tail = _tail_file(latest_log, max_log_lines) if latest_log else ''
125
+ log_tail = _redact(log_tail)
126
+
127
+ try:
128
+ with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as z:
129
+ z.writestr('meta.json', json.dumps(meta, ensure_ascii=True, indent=2))
130
+ if latest_log and log_tail:
131
+ z.writestr('log_tail.txt', log_tail)
132
+ if traceback_text:
133
+ z.writestr('traceback.txt', _redact(traceback_text))
134
+ if include_winscp:
135
+ upload_log = os.path.join(local_storage_path, 'winscp_upload.log')
136
+ download_log = os.path.join(local_storage_path, 'winscp_download.log')
137
+ winscp_logs = [(p, os.path.getmtime(p)) for p in [upload_log, download_log] if os.path.exists(p)]
138
+ if winscp_logs:
139
+ latest_winscp = max(winscp_logs, key=lambda x: x[1])[0]
140
+ winscp_tail = _tail_file(latest_winscp, max_log_lines) if latest_winscp else ''
141
+ winscp_tail = _redact(winscp_tail)
142
+ if winscp_tail:
143
+ z.writestr('winscp_log_tail.txt', winscp_tail)
144
+ return True
145
+ except Exception as e:
146
+ mc_log('Error creating support bundle at {}: {}'.format(zip_path, e), level='ERROR')
147
+ return False
148
+
149
+ # Centralized self-healing helpers for email reporting
150
+ def _attempt_gmail_reauth_interactive(max_wait_seconds=120):
151
+ """
152
+ Delegate to MediLink.MediLink_Gmail.ensure_authenticated_for_gmail_send to
153
+ reuse existing OAuth/server logic without duplicating implementations.
154
+ """
155
+ try:
156
+ from MediLink.MediLink_Gmail import ensure_authenticated_for_gmail_send
157
+ return bool(ensure_authenticated_for_gmail_send(max_wait_seconds=max_wait_seconds))
158
+ except Exception as e:
159
+ try:
160
+ mc_log("Failed to initiate Gmail re-authorization: {0}".format(e), level="ERROR")
161
+ except Exception:
162
+ pass
163
+ return False
164
+
165
+ # _compute_report_id removed (unused)
166
+
167
+
168
+ def collect_support_bundle(include_traceback=True, max_log_lines=500):
169
+ config, _ = load_configuration()
170
+ medi = config.get('MediLink_Config', {})
171
+ local_storage_path = medi.get('local_storage_path', '.')
172
+ queue_dir = _resolve_queue_dir(medi)
173
+
174
+ stamp = time.strftime('%Y%m%d_%H%M%S')
175
+ zip_path = os.path.join(queue_dir, 'support_report_{}.zip'.format(stamp))
176
+
177
+ traceback_txt = ''
178
+ if include_traceback:
179
+ try:
180
+ trace_path = os.path.join(local_storage_path, 'traceback.txt')
181
+ if os.path.exists(trace_path):
182
+ with open(trace_path, 'r') as tf:
183
+ traceback_txt = tf.read()
184
+ except Exception:
185
+ traceback_txt = ''
186
+
187
+ meta = {
188
+ 'app_version': _safe_ascii(_get_version()),
189
+ 'python_version': _safe_ascii(sys.version.split(' ')[0]),
190
+ 'platform': _safe_ascii(platform.platform()),
191
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
192
+ 'error_summary': _safe_ascii(_first_line(traceback_txt)),
193
+ 'traceback_present': bool(traceback_txt),
194
+ 'config_flags': {
195
+ 'console_logging': bool(medi.get('logging', {}).get('console_output', False)),
196
+ 'test_mode': bool(medi.get('TestMode', False))
197
+ }
198
+ }
199
+
200
+ ok = _build_support_zip(zip_path, local_storage_path, max_log_lines, traceback_txt, True, meta)
201
+ return zip_path if ok else None
202
+
203
+
204
+ def collect_test_support_bundle(max_log_lines=500):
205
+ """
206
+ Build a support bundle using the latest available logs and a placeholder
207
+ (fake) traceback to exercise the reporting pipeline without exposing
208
+ real exception data.
209
+
210
+ Returns absolute path to the created ZIP, or None on failure.
211
+ """
212
+ try:
213
+ config, _ = load_configuration()
214
+ medi = config.get('MediLink_Config', {})
215
+ local_storage_path = medi.get('local_storage_path', '.')
216
+ queue_dir = _resolve_queue_dir(medi)
217
+
218
+ stamp = time.strftime('%Y%m%d_%H%M%S')
219
+ zip_path = os.path.join(queue_dir, 'support_report_TEST_{}.zip'.format(stamp))
220
+
221
+ # Build a placeholder traceback - ASCII-only, no real data
222
+ fake_tb = (
223
+ "Traceback (most recent call last):\n"
224
+ " File \"MediCafe/test_runner.py\", line 42, in <module>\n"
225
+ " File \"MediCafe/error_reporter.py\", line 123, in simulate_error\n"
226
+ "Exception: This is a TEST placeholder traceback for pipeline verification only.\n"
227
+ "-- No real patient or PHI data is included. --\n"
228
+ )
229
+ meta = {
230
+ 'app_version': _safe_ascii(_get_version()),
231
+ 'python_version': _safe_ascii(sys.version.split(' ')[0]),
232
+ 'platform': _safe_ascii(platform.platform()),
233
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
234
+ 'error_summary': 'TEST: Placeholder traceback',
235
+ 'traceback_present': True,
236
+ 'config_flags': {
237
+ 'console_logging': bool(medi.get('logging', {}).get('console_output', False)),
238
+ 'test_mode': True
239
+ }
240
+ }
241
+ ok = _build_support_zip(zip_path, local_storage_path, max_log_lines, fake_tb, True, meta)
242
+ return zip_path if ok else None
243
+ except Exception as e:
244
+ try:
245
+ mc_log('Error creating TEST support bundle: {}'.format(e), level='ERROR')
246
+ except Exception:
247
+ pass
248
+ return None
249
+
250
+
251
+ def _first_line(text):
252
+ try:
253
+ for line in (text or '').splitlines():
254
+ line = line.strip()
255
+ if line:
256
+ return line[:200]
257
+ return ''
258
+ except Exception:
259
+ return ''
260
+
261
+
262
+ def _get_version():
263
+ try:
264
+ from MediCafe import __version__
265
+ return __version__
266
+ except Exception:
267
+ return 'unknown'
268
+
269
+
270
+ def capture_unhandled_traceback(exc_type, exc_value, exc_traceback):
271
+ try:
272
+ config, _ = load_configuration()
273
+ medi = config.get('MediLink_Config', {})
274
+ local_storage_path = medi.get('local_storage_path', '.')
275
+ trace_path = os.path.join(local_storage_path, 'traceback.txt')
276
+ text = ''.join(traceback.format_exception(exc_type, exc_value, exc_traceback))
277
+ text = _redact(text)
278
+ with open(trace_path, 'w') as f:
279
+ f.write(text)
280
+ print("An error occurred. A traceback was saved to {}".format(trace_path))
281
+ except Exception:
282
+ try:
283
+ mc_log('Failed to capture traceback to file', level='WARNING')
284
+ except Exception:
285
+ pass
286
+
287
+ def submit_support_bundle_email(zip_path=None, include_traceback=True):
288
+ if not zip_path:
289
+ zip_path = collect_support_bundle(include_traceback)
290
+ if not zip_path:
291
+ mc_log("Failed to create bundle.", level="ERROR")
292
+ try:
293
+ print("Failed to create support bundle. Ensure 'MediLink_Config.local_storage_path' is writable and logs exist.")
294
+ except Exception:
295
+ pass
296
+ return False
297
+ bundle_size = os.path.getsize(zip_path)
298
+ config, _ = load_configuration()
299
+ email_config = config.get('MediLink_Config', {}).get('error_reporting', {}).get('email', {})
300
+ # Determine max size from config with default 1.5MB
301
+ try:
302
+ max_bytes = int(email_config.get('max_bundle_bytes', 1572864))
303
+ except Exception:
304
+ max_bytes = 1572864
305
+ if bundle_size > max_bytes:
306
+ mc_log("Bundle too large ({} bytes > {} bytes) - leaving in queue.".format(bundle_size, max_bytes), level="WARNING")
307
+ try:
308
+ print("Bundle too large to email ({} KB > {} KB). Left in 'reports_queue'. Path: {}".format(int(bundle_size/1024), int(max_bytes/1024), zip_path))
309
+ print("Attempting to create and send a smaller LITE bundle (reduced logs, no traceback).")
310
+ except Exception:
311
+ pass
312
+ # Attempt a smaller bundle automatically
313
+ lite_zip = collect_support_bundle_lite(max_log_lines=200)
314
+ if lite_zip and os.path.exists(lite_zip):
315
+ try:
316
+ lite_size = os.path.getsize(lite_zip)
317
+ except Exception:
318
+ lite_size = -1
319
+ if 0 < lite_size <= max_bytes:
320
+ return submit_support_bundle_email(zip_path=lite_zip, include_traceback=False)
321
+ else:
322
+ try:
323
+ print("LITE bundle is still too large ({} KB).".format(int(max(lite_size, 0)/1024)))
324
+ except Exception:
325
+ pass
326
+ try:
327
+ print("Tip: Reduce log size or increase 'MediLink_Config.error_reporting.email.max_bundle_bytes'.")
328
+ except Exception:
329
+ pass
330
+ return False
331
+ # Feature is always available; proceed if recipients and token are available
332
+ # Normalize and validate recipients
333
+ to_emails = _normalize_recipients(email_config.get('to', []))
334
+ if not to_emails:
335
+ mc_log("No valid recipients configured in error_reporting.email.to", level="ERROR")
336
+ try:
337
+ print("No recipients configured. Set 'MediLink_Config.error_reporting.email.to' to one or more email addresses.")
338
+ except Exception:
339
+ pass
340
+ return False
341
+ subject_prefix = email_config.get('subject_prefix', 'MediCafe Error Report')
342
+ access_token = get_access_token()
343
+ if not access_token:
344
+ mc_log("No access token - attempting Gmail re-authorization.", level="ERROR")
345
+ try:
346
+ print("No Gmail token found. Starting re-authorization...")
347
+ except Exception:
348
+ pass
349
+ if _attempt_gmail_reauth_interactive():
350
+ access_token = get_access_token()
351
+ if not access_token:
352
+ try:
353
+ print("Authentication incomplete. Please finish Gmail consent, then retry.")
354
+ except Exception:
355
+ pass
356
+ return False
357
+ mc_log("Building email...", level="INFO")
358
+ msg = MIMEMultipart()
359
+ msg['To'] = ', '.join(to_emails)
360
+ msg['Subject'] = '{} - {}'.format(subject_prefix, time.strftime('%Y%m%d_%H%M%S'))
361
+ with open(zip_path, 'rb') as f:
362
+ attach = MIMEApplication(f.read(), _subtype='zip')
363
+ attach.add_header('Content-Disposition', 'attachment', filename=os.path.basename(zip_path))
364
+ msg.attach(attach)
365
+ body = "Error report attached."
366
+ msg.attach(MIMEText(body, 'plain'))
367
+ raw = base64.urlsafe_b64encode(msg.as_bytes()).decode()
368
+ mc_log("Sending report...", level="INFO")
369
+ headers = {'Authorization': 'Bearer {}'.format(access_token), 'Content-Type': 'application/json'}
370
+ data = {'raw': raw}
371
+ try:
372
+ resp = requests.post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', headers=headers, json=data)
373
+ except Exception as e:
374
+ mc_log("Network error during Gmail send: {0}".format(e), level="ERROR")
375
+ try:
376
+ print("Network error while sending report: {0}".format(e))
377
+ print("Check internet connectivity or proxy settings and retry.")
378
+ except Exception:
379
+ pass
380
+ return False
381
+ if resp.status_code == 200:
382
+ mc_log("Report sent successfully!", level="INFO")
383
+ try:
384
+ os.remove(zip_path)
385
+ except Exception:
386
+ pass
387
+ return True
388
+ else:
389
+ # Handle auth errors by prompting re-consent using existing OAuth helpers, then retry once
390
+ if resp.status_code in (401, 403):
391
+ mc_log("Gmail send unauthorized ({}). Attempting re-authorization and retry.".format(resp.status_code), level="WARNING")
392
+ try:
393
+ print("Gmail permission issue detected ({}). Starting re-authorization...".format(resp.status_code))
394
+ except Exception:
395
+ pass
396
+ if _attempt_gmail_reauth_interactive():
397
+ new_token = get_access_token()
398
+ if new_token:
399
+ headers['Authorization'] = 'Bearer {}'.format(new_token)
400
+ try:
401
+ resp = requests.post('https://gmail.googleapis.com/gmail/v1/users/me/messages/send', headers=headers, json=data)
402
+ except Exception as e:
403
+ mc_log("Network error on retry: {0}".format(e), level="ERROR")
404
+ try:
405
+ print("Network error on retry: {0}".format(e))
406
+ except Exception:
407
+ pass
408
+ return False
409
+ if resp.status_code == 200:
410
+ mc_log("Report sent successfully after re-authorization!", level="INFO")
411
+ try:
412
+ os.remove(zip_path)
413
+ except Exception:
414
+ pass
415
+ return True
416
+ # Map common Gmail errors to actionable hints
417
+ hint = ''
418
+ try:
419
+ body = resp.json()
420
+ err = body.get('error', {}) if isinstance(body, dict) else {}
421
+ status = (err.get('status') or '').upper()
422
+ message = err.get('message') or ''
423
+ reasons = ','.join([e.get('reason') for e in err.get('errors', []) if isinstance(e, dict) and e.get('reason')])
424
+ if 'RATELIMIT' in status or 'rateLimitExceeded' in reasons:
425
+ hint = 'Quota exceeded. Wait and retry later.'
426
+ elif 'DAILY' in status or 'dailyLimitExceeded' in reasons:
427
+ hint = 'Daily quota exceeded. Try again tomorrow.'
428
+ elif status in ('PERMISSION_DENIED', 'FORBIDDEN'):
429
+ hint = 'Permissions insufficient. Re-authorize Gmail with required scopes.'
430
+ elif status == 'INVALID_ARGUMENT':
431
+ hint = 'Invalid request. Check recipient emails and attachment size.'
432
+ elif status == 'UNAUTHENTICATED':
433
+ hint = 'Authentication required. Re-authorize and retry.'
434
+ except Exception:
435
+ pass
436
+ mc_log("Failed to send: {} - {}".format(resp.status_code, _redact(resp.text)), level="ERROR")
437
+ try:
438
+ base_msg = "Failed to send report: HTTP {}.".format(resp.status_code)
439
+ if hint:
440
+ base_msg += " Hint: {}".format(hint)
441
+ print(base_msg)
442
+ print("The bundle remains in 'reports_queue'. See latest log for details.")
443
+ except Exception:
444
+ pass
445
+ # Preserve bundle in queue for manual retry
446
+ return False
447
+
448
+
449
+ def _normalize_recipients(to_field):
450
+ try:
451
+ # Flatten to a list of strings
452
+ if isinstance(to_field, str):
453
+ separators_normalized = to_field.replace(';', ',').replace('\n', ',').replace('\r', ',')
454
+ candidates = [p.strip() for p in separators_normalized.split(',')]
455
+ elif isinstance(to_field, list):
456
+ candidates = [str(p).strip() for p in to_field]
457
+ else:
458
+ candidates = []
459
+ # Basic email regex: local@domain.tld
460
+ import re
461
+ email_re = re.compile(r'^[^@\s]+@[^@\s]+\.[^@\s]+$')
462
+ valid = []
463
+ for addr in candidates:
464
+ if not addr:
465
+ continue
466
+ if email_re.match(addr):
467
+ valid.append(addr)
468
+ else:
469
+ try:
470
+ mc_log("Invalid email recipient skipped: {}".format(addr), level="WARNING")
471
+ except Exception:
472
+ pass
473
+ return valid
474
+ except Exception:
475
+ return []
476
+
477
+
478
+ def collect_support_bundle_lite(max_log_lines=200):
479
+ """Create a smaller 'lite' support bundle with reduced logs and no traceback/winscp logs."""
480
+ try:
481
+ config, _ = load_configuration()
482
+ medi = config.get('MediLink_Config', {})
483
+ local_storage_path = medi.get('local_storage_path', '.')
484
+ queue_dir = _resolve_queue_dir(medi)
485
+ stamp = time.strftime('%Y%m%d_%H%M%S')
486
+ zip_path = os.path.join(queue_dir, 'support_report_LITE_{}.zip'.format(stamp))
487
+ meta = {
488
+ 'app_version': _safe_ascii(_get_version()),
489
+ 'python_version': _safe_ascii(sys.version.split(' ')[0]),
490
+ 'platform': _safe_ascii(platform.platform()),
491
+ 'timestamp': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
492
+ 'error_summary': 'LITE: No traceback included',
493
+ 'traceback_present': False,
494
+ 'config_flags': {
495
+ 'console_logging': bool(medi.get('logging', {}).get('console_output', False)),
496
+ 'test_mode': bool(medi.get('TestMode', False))
497
+ }
498
+ }
499
+ ok = _build_support_zip(zip_path, local_storage_path, max_log_lines, None, False, meta)
500
+ return zip_path if ok else None
501
+ except Exception:
502
+ return None
503
+
504
+
505
+ def list_queued_bundles():
506
+ try:
507
+ config, _ = load_configuration()
508
+ medi = config.get('MediLink_Config', {})
509
+ local_storage_path = medi.get('local_storage_path', '.')
510
+ primary = os.path.join(local_storage_path, 'reports_queue')
511
+ fallback = os.path.join('.', 'reports_queue')
512
+ files = []
513
+ for q in (primary, fallback):
514
+ try:
515
+ if os.path.isdir(q):
516
+ files.extend([os.path.join(q, f) for f in os.listdir(q) if f.endswith('.zip')])
517
+ except Exception:
518
+ pass
519
+ files = sorted(set(files))
520
+ return files
521
+ except Exception:
522
+ return []
523
+
524
+
525
+ def submit_all_queued_bundles():
526
+ sent = 0
527
+ failed = 0
528
+ try:
529
+ queued = list_queued_bundles()
530
+ for z in queued:
531
+ try:
532
+ ok = submit_support_bundle_email(zip_path=z, include_traceback=False)
533
+ if ok:
534
+ sent += 1
535
+ else:
536
+ failed += 1
537
+ except Exception:
538
+ failed += 1
539
+ except Exception:
540
+ pass
541
+ return sent, failed
542
+
543
+
544
+ def delete_all_queued_bundles():
545
+ deleted = 0
546
+ try:
547
+ for z in list_queued_bundles():
548
+ try:
549
+ os.remove(z)
550
+ deleted += 1
551
+ except Exception:
552
+ pass
553
+ except Exception:
554
+ pass
555
+ return deleted
556
+
557
+ def email_error_report_flow():
558
+ try:
559
+ sent = submit_support_bundle_email(zip_path=None, include_traceback=True)
560
+ return 0 if sent else 1
561
+ except Exception as e:
562
+ mc_log("[ERROR] Exception during email report flow: {0}".format(e), level="ERROR")
563
+ try:
564
+ print("Unexpected error while sending error report: {0}".format(e))
565
+ except Exception:
566
+ pass
567
+ return 1
568
+
569
+ def email_test_error_report_flow():
570
+ """
571
+ Create and send a TEST error report bundle, containing a placeholder
572
+ traceback and latest log tails. Intended for troubleshooting the
573
+ submission pipeline only.
574
+ """
575
+ try:
576
+ zip_path = collect_test_support_bundle()
577
+ if not zip_path:
578
+ try:
579
+ print("Failed to create TEST support bundle. Ensure 'MediLink_Config.local_storage_path' is writable.")
580
+ except Exception:
581
+ pass
582
+ return 1
583
+ sent = submit_support_bundle_email(zip_path=zip_path, include_traceback=False)
584
+ if not sent:
585
+ try:
586
+ print("TEST error report was not sent. See messages above for the reason. The ZIP remains queued if creation succeeded.")
587
+ except Exception:
588
+ pass
589
+ return 0 if sent else 1
590
+ except Exception as e:
591
+ try:
592
+ mc_log("[ERROR] Exception during test email report flow: {0}".format(e), level="ERROR")
593
+ except Exception:
594
+ pass
595
+ try:
596
+ print("Unexpected error during TEST report flow: {0}".format(e))
597
+ except Exception:
598
+ pass
599
+ return 1
600
+
601
+ if __name__ == "__main__":
602
+ raise SystemExit(email_error_report_flow())
603
+
@@ -594,6 +594,71 @@ def clear_token_cache():
594
594
  def check_invalid_grant_causes(auth_code):
595
595
  log("FUTURE IMPLEMENTATION: Checking common causes for invalid_grant error with auth code: {}".format(auth_code))
596
596
 
597
+
598
+ def ensure_authenticated_for_gmail_send(max_wait_seconds=120):
599
+ """Ensure a valid Gmail access token is available for sending.
600
+
601
+ - Reuses existing OAuth helpers in this module.
602
+ - Starts the local HTTPS server if needed, opens the browser for consent,
603
+ and polls for a token for up to max_wait_seconds.
604
+ - Returns True if a usable access token is available after the flow; otherwise False.
605
+ """
606
+ try:
607
+ token = get_access_token()
608
+ except Exception:
609
+ token = None
610
+ if token:
611
+ return True
612
+
613
+ # Prepare server and certificates
614
+ try:
615
+ generate_self_signed_cert(cert_file, key_file)
616
+ except Exception as e:
617
+ log("Warning: could not ensure self-signed certs: {}".format(e))
618
+
619
+ server_started_here = False
620
+ global httpd
621
+ try:
622
+ if httpd is None:
623
+ log("Starting local HTTPS server for OAuth redirect handling.")
624
+ server_thread = Thread(target=run_server)
625
+ server_thread.daemon = True
626
+ server_thread.start()
627
+ server_started_here = True
628
+ time.sleep(0.5)
629
+ except Exception as e:
630
+ log("Failed to start OAuth local server: {}".format(e))
631
+
632
+ try:
633
+ auth_url = get_authorization_url()
634
+ print("Opening browser to authorize Gmail permission for sending...")
635
+ open_browser_with_executable(auth_url)
636
+ except Exception as e:
637
+ log("Failed to open authorization URL: {}".format(e))
638
+
639
+ # Poll for token availability within timeout
640
+ start_ts = time.time()
641
+ token = None
642
+ while time.time() - start_ts < max_wait_seconds:
643
+ try:
644
+ token = get_access_token()
645
+ except Exception:
646
+ token = None
647
+ if token:
648
+ break
649
+ time.sleep(3)
650
+
651
+ if server_started_here:
652
+ try:
653
+ stop_server()
654
+ except Exception:
655
+ pass
656
+
657
+ if not token:
658
+ print("Gmail authorization not completed within timeout. Please finish consent and retry.")
659
+
660
+ return bool(token)
661
+
597
662
  if __name__ == "__main__":
598
663
  signal.signal(signal.SIGINT, signal_handler)
599
664
  signal.signal(signal.SIGTERM, signal_handler)