duckguard 2.2.0__py3-none-any.whl → 3.0.0__py3-none-any.whl

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.
Files changed (39) hide show
  1. duckguard/__init__.py +1 -1
  2. duckguard/anomaly/__init__.py +28 -0
  3. duckguard/anomaly/baselines.py +294 -0
  4. duckguard/anomaly/methods.py +16 -2
  5. duckguard/anomaly/ml_methods.py +724 -0
  6. duckguard/checks/__init__.py +26 -0
  7. duckguard/checks/conditional.py +796 -0
  8. duckguard/checks/distributional.py +524 -0
  9. duckguard/checks/multicolumn.py +726 -0
  10. duckguard/checks/query_based.py +643 -0
  11. duckguard/cli/main.py +257 -2
  12. duckguard/connectors/factory.py +30 -2
  13. duckguard/connectors/files.py +7 -3
  14. duckguard/core/column.py +851 -1
  15. duckguard/core/dataset.py +1035 -0
  16. duckguard/core/result.py +236 -0
  17. duckguard/freshness/__init__.py +33 -0
  18. duckguard/freshness/monitor.py +429 -0
  19. duckguard/history/schema.py +119 -1
  20. duckguard/notifications/__init__.py +20 -2
  21. duckguard/notifications/email.py +508 -0
  22. duckguard/profiler/distribution_analyzer.py +384 -0
  23. duckguard/profiler/outlier_detector.py +497 -0
  24. duckguard/profiler/pattern_matcher.py +301 -0
  25. duckguard/profiler/quality_scorer.py +445 -0
  26. duckguard/reports/html_reporter.py +1 -2
  27. duckguard/rules/executor.py +642 -0
  28. duckguard/rules/generator.py +4 -1
  29. duckguard/rules/schema.py +54 -0
  30. duckguard/schema_history/__init__.py +40 -0
  31. duckguard/schema_history/analyzer.py +414 -0
  32. duckguard/schema_history/tracker.py +288 -0
  33. duckguard/semantic/detector.py +17 -1
  34. duckguard-3.0.0.dist-info/METADATA +1072 -0
  35. {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/RECORD +38 -21
  36. duckguard-2.2.0.dist-info/METADATA +0 -351
  37. {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/WHEEL +0 -0
  38. {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/entry_points.txt +0 -0
  39. {duckguard-2.2.0.dist-info → duckguard-3.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -7,7 +7,7 @@ enabling trend analysis and historical comparison.
7
7
  from __future__ import annotations
8
8
 
9
9
  # Schema version for migrations
10
- SCHEMA_VERSION = 1
10
+ SCHEMA_VERSION = 2
11
11
 
12
12
  # SQL to create all tables
13
13
  CREATE_TABLES_SQL = """
@@ -88,6 +88,57 @@ CREATE INDEX IF NOT EXISTS idx_runs_source_started ON runs(source, started_at);
88
88
  CREATE INDEX IF NOT EXISTS idx_check_results_run_id ON check_results(run_id);
89
89
  CREATE INDEX IF NOT EXISTS idx_failed_rows_run_id ON failed_rows_sample(run_id);
90
90
  CREATE INDEX IF NOT EXISTS idx_quality_trends_source_date ON quality_trends(source, date);
91
+
92
+ -- Schema snapshots: Store schema state at points in time
93
+ CREATE TABLE IF NOT EXISTS schema_snapshots (
94
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
95
+ source TEXT NOT NULL,
96
+ snapshot_id TEXT UNIQUE NOT NULL,
97
+ captured_at TEXT NOT NULL,
98
+ schema_json TEXT NOT NULL,
99
+ column_count INTEGER NOT NULL,
100
+ row_count INTEGER,
101
+ created_at TEXT DEFAULT (datetime('now'))
102
+ );
103
+
104
+ -- Schema changes: Track schema evolution over time
105
+ CREATE TABLE IF NOT EXISTS schema_changes (
106
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
107
+ source TEXT NOT NULL,
108
+ detected_at TEXT NOT NULL,
109
+ previous_snapshot_id TEXT,
110
+ current_snapshot_id TEXT NOT NULL,
111
+ change_type TEXT NOT NULL,
112
+ column_name TEXT,
113
+ previous_value TEXT,
114
+ current_value TEXT,
115
+ is_breaking INTEGER NOT NULL,
116
+ severity TEXT NOT NULL,
117
+ created_at TEXT DEFAULT (datetime('now')),
118
+ FOREIGN KEY (previous_snapshot_id) REFERENCES schema_snapshots(snapshot_id),
119
+ FOREIGN KEY (current_snapshot_id) REFERENCES schema_snapshots(snapshot_id)
120
+ );
121
+
122
+ -- Baselines: Store learned baselines for anomaly detection
123
+ CREATE TABLE IF NOT EXISTS baselines (
124
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
125
+ source TEXT NOT NULL,
126
+ column_name TEXT NOT NULL,
127
+ metric TEXT NOT NULL,
128
+ baseline_value TEXT NOT NULL,
129
+ sample_size INTEGER,
130
+ created_at TEXT NOT NULL,
131
+ updated_at TEXT,
132
+ UNIQUE(source, column_name, metric)
133
+ );
134
+
135
+ -- Additional indexes for new tables
136
+ CREATE INDEX IF NOT EXISTS idx_schema_snapshots_source ON schema_snapshots(source);
137
+ CREATE INDEX IF NOT EXISTS idx_schema_snapshots_captured_at ON schema_snapshots(captured_at);
138
+ CREATE INDEX IF NOT EXISTS idx_schema_changes_source ON schema_changes(source);
139
+ CREATE INDEX IF NOT EXISTS idx_schema_changes_detected_at ON schema_changes(detected_at);
140
+ CREATE INDEX IF NOT EXISTS idx_baselines_source ON baselines(source);
141
+ CREATE INDEX IF NOT EXISTS idx_baselines_source_column ON baselines(source, column_name);
91
142
  """
92
143
 
93
144
  # Pre-built queries for common operations
@@ -180,4 +231,71 @@ QUERIES = {
180
231
  SELECT * FROM runs
181
232
  WHERE run_id = ?
182
233
  """,
234
+ # Schema snapshot queries
235
+ "insert_schema_snapshot": """
236
+ INSERT INTO schema_snapshots (
237
+ source, snapshot_id, captured_at, schema_json, column_count, row_count
238
+ ) VALUES (?, ?, ?, ?, ?, ?)
239
+ """,
240
+ "get_schema_snapshots": """
241
+ SELECT * FROM schema_snapshots
242
+ WHERE source = ?
243
+ ORDER BY captured_at DESC
244
+ LIMIT ?
245
+ """,
246
+ "get_latest_schema_snapshot": """
247
+ SELECT * FROM schema_snapshots
248
+ WHERE source = ?
249
+ ORDER BY captured_at DESC
250
+ LIMIT 1
251
+ """,
252
+ "get_schema_snapshot_by_id": """
253
+ SELECT * FROM schema_snapshots
254
+ WHERE snapshot_id = ?
255
+ """,
256
+ # Schema change queries
257
+ "insert_schema_change": """
258
+ INSERT INTO schema_changes (
259
+ source, detected_at, previous_snapshot_id, current_snapshot_id,
260
+ change_type, column_name, previous_value, current_value,
261
+ is_breaking, severity
262
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
263
+ """,
264
+ "get_schema_changes": """
265
+ SELECT * FROM schema_changes
266
+ WHERE source = ?
267
+ ORDER BY detected_at DESC
268
+ LIMIT ?
269
+ """,
270
+ "get_schema_changes_since": """
271
+ SELECT * FROM schema_changes
272
+ WHERE source = ?
273
+ AND detected_at >= ?
274
+ ORDER BY detected_at DESC
275
+ """,
276
+ # Baseline queries
277
+ "upsert_baseline": """
278
+ INSERT INTO baselines (
279
+ source, column_name, metric, baseline_value, sample_size, created_at, updated_at
280
+ ) VALUES (?, ?, ?, ?, ?, ?, ?)
281
+ ON CONFLICT(source, column_name, metric) DO UPDATE SET
282
+ baseline_value = excluded.baseline_value,
283
+ sample_size = excluded.sample_size,
284
+ updated_at = excluded.updated_at
285
+ """,
286
+ "get_baseline": """
287
+ SELECT * FROM baselines
288
+ WHERE source = ?
289
+ AND column_name = ?
290
+ AND metric = ?
291
+ """,
292
+ "get_baselines_for_source": """
293
+ SELECT * FROM baselines
294
+ WHERE source = ?
295
+ ORDER BY column_name, metric
296
+ """,
297
+ "delete_baselines_for_source": """
298
+ DELETE FROM baselines
299
+ WHERE source = ?
300
+ """,
183
301
  }
@@ -1,8 +1,8 @@
1
1
  """
2
- DuckGuard Notifications - Slack and Teams alerting for data quality checks.
2
+ DuckGuard Notifications - Slack, Teams, and Email alerting for data quality checks.
3
3
 
4
4
  Usage:
5
- from duckguard.notifications import SlackNotifier, TeamsNotifier
5
+ from duckguard.notifications import SlackNotifier, TeamsNotifier, EmailNotifier
6
6
 
7
7
  # Slack
8
8
  slack = SlackNotifier(webhook_url="https://hooks.slack.com/...")
@@ -12,6 +12,15 @@ Usage:
12
12
  teams = TeamsNotifier(webhook_url="https://outlook.office.com/webhook/...")
13
13
  teams.send_results(execution_result)
14
14
 
15
+ # Email
16
+ email = EmailNotifier(
17
+ smtp_host="smtp.gmail.com",
18
+ smtp_user="alerts@company.com",
19
+ smtp_password="app_password",
20
+ to_addresses=["team@company.com"],
21
+ )
22
+ email.send_results(execution_result)
23
+
15
24
  # Auto-notify on failures
16
25
  from duckguard import execute_rules, load_rules
17
26
 
@@ -20,8 +29,13 @@ Usage:
20
29
 
21
30
  if not result.passed:
22
31
  slack.send_failure_alert(result)
32
+ email.send_failure_alert(result)
23
33
  """
24
34
 
35
+ from duckguard.notifications.email import (
36
+ EmailConfig,
37
+ EmailNotifier,
38
+ )
25
39
  from duckguard.notifications.formatter import (
26
40
  format_results_markdown,
27
41
  format_results_text,
@@ -29,6 +43,7 @@ from duckguard.notifications.formatter import (
29
43
  from duckguard.notifications.notifiers import (
30
44
  BaseNotifier,
31
45
  NotificationConfig,
46
+ NotificationError,
32
47
  SlackNotifier,
33
48
  TeamsNotifier,
34
49
  )
@@ -36,8 +51,11 @@ from duckguard.notifications.notifiers import (
36
51
  __all__ = [
37
52
  "BaseNotifier",
38
53
  "NotificationConfig",
54
+ "NotificationError",
39
55
  "SlackNotifier",
40
56
  "TeamsNotifier",
57
+ "EmailNotifier",
58
+ "EmailConfig",
41
59
  "format_results_text",
42
60
  "format_results_markdown",
43
61
  ]
@@ -0,0 +1,508 @@
1
+ """Email notification provider for DuckGuard.
2
+
3
+ Provides SMTP-based email notifications for data quality alerts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import os
10
+ import smtplib
11
+ import ssl
12
+ from dataclasses import dataclass, field
13
+ from datetime import datetime
14
+ from email.mime.multipart import MIMEMultipart
15
+ from email.mime.text import MIMEText
16
+ from typing import Any
17
+
18
+ from duckguard.notifications.notifiers import BaseNotifier, NotificationConfig, NotificationError
19
+ from duckguard.rules.executor import ExecutionResult
20
+
21
+
22
+ @dataclass
23
+ class EmailConfig:
24
+ """Email configuration.
25
+
26
+ Attributes:
27
+ smtp_host: SMTP server hostname
28
+ smtp_port: SMTP server port (default: 587 for TLS)
29
+ smtp_user: SMTP username for authentication
30
+ smtp_password: SMTP password for authentication
31
+ from_address: Email address to send from
32
+ to_addresses: List of recipient email addresses
33
+ use_tls: Whether to use TLS encryption (default: True)
34
+ use_ssl: Whether to use SSL (default: False, use for port 465)
35
+ subject_prefix: Prefix for email subjects
36
+ """
37
+
38
+ smtp_host: str
39
+ smtp_port: int = 587
40
+ smtp_user: str | None = None
41
+ smtp_password: str | None = None
42
+ from_address: str | None = None
43
+ to_addresses: list[str] = field(default_factory=list)
44
+ use_tls: bool = True
45
+ use_ssl: bool = False
46
+ subject_prefix: str = "[DuckGuard]"
47
+
48
+
49
+ class EmailNotifier(BaseNotifier):
50
+ """Email notification provider via SMTP.
51
+
52
+ Sends HTML-formatted email notifications when data quality checks
53
+ fail or meet other notification conditions.
54
+
55
+ Usage:
56
+ from duckguard.notifications import EmailNotifier
57
+
58
+ # Direct configuration
59
+ notifier = EmailNotifier(
60
+ smtp_host="smtp.gmail.com",
61
+ smtp_port=587,
62
+ smtp_user="alerts@company.com",
63
+ smtp_password="app_password",
64
+ from_address="alerts@company.com",
65
+ to_addresses=["team@company.com", "oncall@company.com"],
66
+ )
67
+
68
+ # Or via environment variable
69
+ # DUCKGUARD_EMAIL_CONFIG='{"smtp_host": "smtp.gmail.com", ...}'
70
+ notifier = EmailNotifier()
71
+
72
+ result = execute_rules(rules, dataset)
73
+ notifier.send_results(result)
74
+
75
+ Environment Variable Format (DUCKGUARD_EMAIL_CONFIG):
76
+ {
77
+ "smtp_host": "smtp.gmail.com",
78
+ "smtp_port": 587,
79
+ "smtp_user": "user@gmail.com",
80
+ "smtp_password": "app_password",
81
+ "from_address": "alerts@company.com",
82
+ "to_addresses": ["team@company.com"]
83
+ }
84
+ """
85
+
86
+ def __init__(
87
+ self,
88
+ smtp_host: str | None = None,
89
+ smtp_port: int = 587,
90
+ smtp_user: str | None = None,
91
+ smtp_password: str | None = None,
92
+ from_address: str | None = None,
93
+ to_addresses: list[str] | None = None,
94
+ use_tls: bool = True,
95
+ use_ssl: bool = False,
96
+ subject_prefix: str = "[DuckGuard]",
97
+ config: NotificationConfig | None = None,
98
+ ):
99
+ """Initialize email notifier.
100
+
101
+ Args:
102
+ smtp_host: SMTP server hostname
103
+ smtp_port: SMTP server port
104
+ smtp_user: SMTP username
105
+ smtp_password: SMTP password
106
+ from_address: Sender email address
107
+ to_addresses: List of recipient addresses
108
+ use_tls: Use STARTTLS
109
+ use_ssl: Use SSL (for port 465)
110
+ subject_prefix: Subject line prefix
111
+ config: Notification configuration
112
+ """
113
+ self.config = config or NotificationConfig()
114
+
115
+ # Try to load from environment if not provided
116
+ if smtp_host is None:
117
+ env_config = self._load_config_from_env()
118
+ if env_config:
119
+ self.email_config = env_config
120
+ else:
121
+ raise ValueError(
122
+ "Email configuration required. Set DUCKGUARD_EMAIL_CONFIG environment variable "
123
+ "or pass smtp_host parameter."
124
+ )
125
+ else:
126
+ self.email_config = EmailConfig(
127
+ smtp_host=smtp_host,
128
+ smtp_port=smtp_port,
129
+ smtp_user=smtp_user,
130
+ smtp_password=smtp_password,
131
+ from_address=from_address or smtp_user,
132
+ to_addresses=to_addresses or [],
133
+ use_tls=use_tls,
134
+ use_ssl=use_ssl,
135
+ subject_prefix=subject_prefix,
136
+ )
137
+
138
+ if not self.email_config.to_addresses:
139
+ raise ValueError("At least one recipient address (to_addresses) is required")
140
+
141
+ # Set webhook_url to a placeholder (not used for email)
142
+ self.webhook_url = "email://smtp"
143
+
144
+ @property
145
+ def _env_var_name(self) -> str:
146
+ return "DUCKGUARD_EMAIL_CONFIG"
147
+
148
+ def _load_config_from_env(self) -> EmailConfig | None:
149
+ """Load configuration from environment variable."""
150
+ env_value = os.environ.get(self._env_var_name)
151
+ if not env_value:
152
+ return None
153
+
154
+ try:
155
+ data = json.loads(env_value)
156
+ return EmailConfig(
157
+ smtp_host=data["smtp_host"],
158
+ smtp_port=data.get("smtp_port", 587),
159
+ smtp_user=data.get("smtp_user"),
160
+ smtp_password=data.get("smtp_password"),
161
+ from_address=data.get("from_address", data.get("smtp_user")),
162
+ to_addresses=data.get("to_addresses", []),
163
+ use_tls=data.get("use_tls", True),
164
+ use_ssl=data.get("use_ssl", False),
165
+ subject_prefix=data.get("subject_prefix", "[DuckGuard]"),
166
+ )
167
+ except (json.JSONDecodeError, KeyError) as e:
168
+ raise ValueError(f"Invalid {self._env_var_name} format: {e}") from e
169
+
170
+ def _format_message(self, result: ExecutionResult) -> dict[str, Any]:
171
+ """Format the result as email subject and body.
172
+
173
+ Returns:
174
+ Dict with 'subject' and 'html_body' keys
175
+ """
176
+ status = "PASSED" if result.passed else "FAILED"
177
+ subject = f"{self.email_config.subject_prefix} Validation {status}: {result.source}"
178
+
179
+ html_body = self._generate_html_body(result)
180
+
181
+ return {
182
+ "subject": subject,
183
+ "html_body": html_body,
184
+ "text_body": self._generate_text_body(result),
185
+ }
186
+
187
+ def _generate_html_body(self, result: ExecutionResult) -> str:
188
+ """Generate HTML email body."""
189
+ status_color = "#28a745" if result.passed else "#dc3545"
190
+ status_text = "PASSED" if result.passed else "FAILED"
191
+
192
+ # Get failures and warnings
193
+ failures = result.get_failures()
194
+ warnings = result.get_warnings()
195
+
196
+ html = f"""
197
+ <!DOCTYPE html>
198
+ <html>
199
+ <head>
200
+ <meta charset="utf-8">
201
+ <style>
202
+ body {{
203
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
204
+ line-height: 1.6;
205
+ color: #333;
206
+ max-width: 600px;
207
+ margin: 0 auto;
208
+ padding: 20px;
209
+ }}
210
+ .header {{
211
+ background: {status_color};
212
+ color: white;
213
+ padding: 20px;
214
+ border-radius: 8px 8px 0 0;
215
+ text-align: center;
216
+ }}
217
+ .header h1 {{
218
+ margin: 0;
219
+ font-size: 24px;
220
+ }}
221
+ .content {{
222
+ background: #f8f9fa;
223
+ padding: 20px;
224
+ border: 1px solid #dee2e6;
225
+ border-top: none;
226
+ border-radius: 0 0 8px 8px;
227
+ }}
228
+ .stats {{
229
+ display: flex;
230
+ justify-content: space-around;
231
+ margin: 20px 0;
232
+ flex-wrap: wrap;
233
+ }}
234
+ .stat {{
235
+ text-align: center;
236
+ padding: 10px 20px;
237
+ background: white;
238
+ border-radius: 8px;
239
+ margin: 5px;
240
+ min-width: 100px;
241
+ }}
242
+ .stat-value {{
243
+ font-size: 24px;
244
+ font-weight: bold;
245
+ color: #333;
246
+ }}
247
+ .stat-label {{
248
+ font-size: 12px;
249
+ color: #666;
250
+ text-transform: uppercase;
251
+ }}
252
+ .section {{
253
+ margin: 20px 0;
254
+ }}
255
+ .section h3 {{
256
+ color: #333;
257
+ border-bottom: 2px solid #dee2e6;
258
+ padding-bottom: 5px;
259
+ }}
260
+ .failure {{
261
+ background: #fff3f3;
262
+ border-left: 4px solid #dc3545;
263
+ padding: 10px 15px;
264
+ margin: 10px 0;
265
+ border-radius: 0 4px 4px 0;
266
+ }}
267
+ .warning {{
268
+ background: #fff8e6;
269
+ border-left: 4px solid #ffc107;
270
+ padding: 10px 15px;
271
+ margin: 10px 0;
272
+ border-radius: 0 4px 4px 0;
273
+ }}
274
+ .column-name {{
275
+ font-weight: bold;
276
+ color: #495057;
277
+ }}
278
+ .footer {{
279
+ text-align: center;
280
+ margin-top: 20px;
281
+ font-size: 12px;
282
+ color: #6c757d;
283
+ }}
284
+ .score-badge {{
285
+ display: inline-block;
286
+ padding: 5px 15px;
287
+ border-radius: 20px;
288
+ font-weight: bold;
289
+ margin-top: 10px;
290
+ }}
291
+ .score-a {{ background: #28a745; color: white; }}
292
+ .score-b {{ background: #5cb85c; color: white; }}
293
+ .score-c {{ background: #ffc107; color: black; }}
294
+ .score-d {{ background: #fd7e14; color: white; }}
295
+ .score-f {{ background: #dc3545; color: white; }}
296
+ </style>
297
+ </head>
298
+ <body>
299
+ <div class="header">
300
+ <h1>DuckGuard Validation {status_text}</h1>
301
+ <p style="margin: 5px 0; opacity: 0.9;">{result.source}</p>
302
+ </div>
303
+
304
+ <div class="content">
305
+ <div class="stats">
306
+ <div class="stat">
307
+ <div class="stat-value">{result.quality_score:.1f}%</div>
308
+ <div class="stat-label">Quality Score</div>
309
+ </div>
310
+ <div class="stat">
311
+ <div class="stat-value">{result.passed_count}/{result.total_checks}</div>
312
+ <div class="stat-label">Checks Passed</div>
313
+ </div>
314
+ <div class="stat">
315
+ <div class="stat-value">{result.failed_count}</div>
316
+ <div class="stat-label">Failures</div>
317
+ </div>
318
+ <div class="stat">
319
+ <div class="stat-value">{result.warning_count}</div>
320
+ <div class="stat-label">Warnings</div>
321
+ </div>
322
+ </div>
323
+ """
324
+
325
+ # Add failures section
326
+ if failures:
327
+ html += """
328
+ <div class="section">
329
+ <h3>Failures</h3>
330
+ """
331
+ for f in failures[:self.config.max_failures_shown]:
332
+ col_name = f"[{f.column}]" if f.column else "[table]"
333
+ html += f"""
334
+ <div class="failure">
335
+ <span class="column-name">{col_name}</span> {f.message}
336
+ </div>
337
+ """
338
+ remaining = len(failures) - self.config.max_failures_shown
339
+ if remaining > 0:
340
+ html += f"""
341
+ <p style="color: #6c757d; font-style: italic;">...and {remaining} more failures</p>
342
+ """
343
+ html += " </div>\n"
344
+
345
+ # Add warnings section
346
+ if warnings and self.config.on_warning:
347
+ html += """
348
+ <div class="section">
349
+ <h3>Warnings</h3>
350
+ """
351
+ for w in warnings[:self.config.max_failures_shown]:
352
+ col_name = f"[{w.column}]" if w.column else "[table]"
353
+ html += f"""
354
+ <div class="warning">
355
+ <span class="column-name">{col_name}</span> {w.message}
356
+ </div>
357
+ """
358
+ remaining = len(warnings) - self.config.max_failures_shown
359
+ if remaining > 0:
360
+ html += f"""
361
+ <p style="color: #6c757d; font-style: italic;">...and {remaining} more warnings</p>
362
+ """
363
+ html += " </div>\n"
364
+
365
+ html += f"""
366
+ </div>
367
+
368
+ <div class="footer">
369
+ <p>Generated by DuckGuard at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
370
+ </div>
371
+ </body>
372
+ </html>
373
+ """
374
+ return html
375
+
376
+ def _generate_text_body(self, result: ExecutionResult) -> str:
377
+ """Generate plain text email body."""
378
+ status = "PASSED" if result.passed else "FAILED"
379
+ lines = [
380
+ f"DuckGuard Validation {status}",
381
+ f"Source: {result.source}",
382
+ f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
383
+ "",
384
+ f"Quality Score: {result.quality_score:.1f}%",
385
+ f"Checks: {result.passed_count}/{result.total_checks} passed",
386
+ f"Failures: {result.failed_count}",
387
+ f"Warnings: {result.warning_count}",
388
+ "",
389
+ ]
390
+
391
+ failures = result.get_failures()
392
+ if failures:
393
+ lines.append("FAILURES:")
394
+ for f in failures[:self.config.max_failures_shown]:
395
+ col_name = f"[{f.column}]" if f.column else "[table]"
396
+ lines.append(f" - {col_name} {f.message}")
397
+ remaining = len(failures) - self.config.max_failures_shown
398
+ if remaining > 0:
399
+ lines.append(f" ...and {remaining} more")
400
+ lines.append("")
401
+
402
+ warnings = result.get_warnings()
403
+ if warnings and self.config.on_warning:
404
+ lines.append("WARNINGS:")
405
+ for w in warnings[:self.config.max_failures_shown]:
406
+ col_name = f"[{w.column}]" if w.column else "[table]"
407
+ lines.append(f" - {col_name} {w.message}")
408
+ remaining = len(warnings) - self.config.max_failures_shown
409
+ if remaining > 0:
410
+ lines.append(f" ...and {remaining} more")
411
+
412
+ return "\n".join(lines)
413
+
414
+ def _send(self, result: ExecutionResult) -> bool:
415
+ """Send the email notification.
416
+
417
+ Args:
418
+ result: ExecutionResult to send
419
+
420
+ Returns:
421
+ True if sent successfully
422
+ """
423
+ message_data = self._format_message(result)
424
+
425
+ # Create message
426
+ msg = MIMEMultipart("alternative")
427
+ msg["Subject"] = message_data["subject"]
428
+ msg["From"] = self.email_config.from_address or self.email_config.smtp_user or ""
429
+ msg["To"] = ", ".join(self.email_config.to_addresses)
430
+
431
+ # Attach text and HTML parts
432
+ text_part = MIMEText(message_data["text_body"], "plain")
433
+ html_part = MIMEText(message_data["html_body"], "html")
434
+ msg.attach(text_part)
435
+ msg.attach(html_part)
436
+
437
+ try:
438
+ if self.email_config.use_ssl:
439
+ # SSL connection (port 465)
440
+ context = ssl.create_default_context()
441
+ with smtplib.SMTP_SSL(
442
+ self.email_config.smtp_host,
443
+ self.email_config.smtp_port,
444
+ context=context,
445
+ ) as server:
446
+ if self.email_config.smtp_user and self.email_config.smtp_password:
447
+ server.login(self.email_config.smtp_user, self.email_config.smtp_password)
448
+ server.sendmail(
449
+ msg["From"],
450
+ self.email_config.to_addresses,
451
+ msg.as_string(),
452
+ )
453
+ else:
454
+ # TLS connection (port 587)
455
+ with smtplib.SMTP(
456
+ self.email_config.smtp_host,
457
+ self.email_config.smtp_port,
458
+ ) as server:
459
+ if self.email_config.use_tls:
460
+ server.starttls()
461
+ if self.email_config.smtp_user and self.email_config.smtp_password:
462
+ server.login(self.email_config.smtp_user, self.email_config.smtp_password)
463
+ server.sendmail(
464
+ msg["From"],
465
+ self.email_config.to_addresses,
466
+ msg.as_string(),
467
+ )
468
+
469
+ return True
470
+
471
+ except smtplib.SMTPException as e:
472
+ raise NotificationError(f"Failed to send email: {e}") from e
473
+ except Exception as e:
474
+ raise NotificationError(f"Email error: {e}") from e
475
+
476
+ def send_results(self, result: ExecutionResult) -> bool:
477
+ """Send notification based on execution results.
478
+
479
+ Args:
480
+ result: ExecutionResult from rule execution
481
+
482
+ Returns:
483
+ True if notification was sent, False if skipped
484
+ """
485
+ should_send = False
486
+
487
+ if not result.passed and self.config.on_failure:
488
+ should_send = True
489
+ elif result.warning_count > 0 and self.config.on_warning:
490
+ should_send = True
491
+ elif result.passed and self.config.on_success:
492
+ should_send = True
493
+
494
+ if not should_send:
495
+ return False
496
+
497
+ return self._send(result)
498
+
499
+ def send_failure_alert(self, result: ExecutionResult) -> bool:
500
+ """Send an alert for failures (ignores config settings).
501
+
502
+ Args:
503
+ result: ExecutionResult from rule execution
504
+
505
+ Returns:
506
+ True if sent successfully
507
+ """
508
+ return self._send(result)