diff --git a/CODE_REVIEW_AND_INTEGRATION_PLAN.md b/CODE_REVIEW_AND_INTEGRATION_PLAN.md new file mode 100644 index 0000000..adc34fc --- /dev/null +++ b/CODE_REVIEW_AND_INTEGRATION_PLAN.md @@ -0,0 +1,958 @@ +# M365 Digest Email System - Critical Code Review & Integration Plan + +**Review Date:** December 15, 2025 +**Reviewer:** Claude Code Analysis +**Severity Levels:** CRITICAL | HIGH | MEDIUM | LOW + +--- + +## Executive Summary + +This codebase represents a **functional but incomplete** M365 email digest system. While the core sending mechanism works, the solution has significant gaps that prevent it from being production-ready for enterprise use. The code demonstrates good PowerShell fundamentals but lacks modern practices, security hardening, and operational maturity. + +**Overall Assessment: 5.5/10** - Proof of concept quality, not production-ready. + +--- + +## PART 1: CRITICAL CODE REVIEW + +### 1. SECURITY VULNERABILITIES + +#### 1.1 CRITICAL: Hardcoded Credentials in Source Code + +**File:** `Send-M365Digest-BasicAuth.ps1:76-77` +```powershell +$smtpUsername = "adm_huebener@elkw.de" +$smtpPassword = "YourSecurePassword123!" # TODO: Replace with secure storage +``` + +**Impact:** If this file is committed with real credentials, they are permanently exposed in git history. + +**Verdict:** The TODO comment acknowledges the problem but the architecture doesn't enforce secure credential handling. No `SecureString` encryption at rest, no Windows Credential Manager integration, no Azure Key Vault integration baked in. + +--- + +#### 1.2 CRITICAL: OAuth Client Secret Exposure Risk + +**File:** `M365DigestEmailModule.psm1:74-79` +```powershell +$body = @{ + client_id = $ClientId + client_secret = $ClientSecret # Passed in plain text + scope = "https://outlook.office365.com/.default" + grant_type = "client_credentials" +} +``` + +**Issue:** Client secrets are passed as plain strings through the call chain. No secure string handling, no secret manager integration. + +**Real-world impact:** Scripts get shared via email, Teams, file shares - secrets leak constantly. + +--- + +#### 1.3 HIGH: No Input Validation on Email Addresses + +**File:** `M365DigestEmailModule.psm1:278` +```powershell +$mailMessage.To.Add($To) # No validation +``` + +**Missing:** +- Email format validation (RFC 5322) +- Domain validation +- Blocklist checking +- Duplicate detection + +A malformed email in the CSV crashes the entire batch. + +--- + +#### 1.4 HIGH: No CSRF/Injection Protection in Template Processing + +**File:** `M365DigestEmailModule.psm1:129-135` +```powershell +Add-Type -AssemblyName System.Web +foreach ($key in $Replacements.Keys) { + $value = $Replacements[$key] + $encodedValue = [System.Web.HttpUtility]::HtmlEncode($value) + $htmlContent = $htmlContent.Replace($key, $encodedValue) +} +``` + +**Good:** HTML encoding is applied. + +**Bad:** +- Link placeholders (CARD1_LINK, CARD2_LINK) are also encoded, breaking URLs +- No URL validation - malicious URLs could be injected +- JavaScript: URLs would pass through + +--- + +### 2. MISSING COMPONENTS (Ghost References) + +#### 2.1 CRITICAL: Send-M365Digest-OAuth.ps1 DOES NOT EXIST + +**Files referencing it:** +- `README.md:23` - "Send-M365Digest-OAuth.ps1" +- `README.md:52-53` - Example usage +- `DEPLOYMENT_GUIDE.md:101` - "Option B: OAuth2 Authentication" +- `QUICK_REFERENCE.md:36-43` - OAuth commands +- `ARCHITECTURE.md:404` - Listed in dependencies + +**Reality:** The file is not in the repository. Users following documentation will fail immediately. + +**This is documentation-driven development without the development.** + +--- + +#### 2.2 HIGH: No Failure Log File + +**File:** `M365DigestEmailModule.psm1:458-459` +```powershell +Write-Warning " ✗ Permanent failure after $MaxRetries attempts: $email" +``` + +Failures are written to console only. No `failed_recipients.csv` is generated. After a 10,000 email campaign, you have no record of which addresses failed permanently. + +--- + +#### 2.3 MEDIUM: Checkpoint File Has No Atomic Write + +**File:** `M365DigestEmailModule.psm1:448` +```powershell +Add-Content -LiteralPath $CheckpointPath -Value $email +``` + +If the script crashes mid-write, the checkpoint file could be corrupted. Should use: +- Write to temp file +- Atomic rename +- Or use SQLite/database + +--- + +### 3. ARCHITECTURAL WEAKNESSES + +#### 3.1 HIGH: Legacy SMTP Instead of Microsoft Graph API + +The entire system is built on `System.Net.Mail.SmtpClient` which: +- Is marked as **obsolete** by Microsoft +- Doesn't support modern OAuth properly (workarounds required) +- Lacks delivery tracking +- Has poor error handling for Microsoft 365 + +**Microsoft's recommendation:** Use Microsoft Graph API `/sendMail` endpoint. + +--- + +#### 3.2 HIGH: No Connection Pooling/Reuse + +**File:** `M365DigestEmailModule.psm1:302-308` +```powershell +$smtpClient = New-Object System.Net.Mail.SmtpClient($SmtpServer, $SmtpPort) +# ... used once ... +$smtpClient.Dispose() +``` + +A new SMTP connection is created for EVERY email. This is: +- Inefficient (TCP handshake + TLS negotiation per email) +- Rate-limit triggering (looks like spam behavior) +- Slow (adds 200-500ms per email) + +Should batch emails on a persistent connection. + +--- + +#### 3.3 HIGH: Synchronous Single-Threaded Processing + +**File:** `M365DigestEmailModule.psm1:412-461` + +The entire batch loop is sequential: +``` +Email 1 -> wait -> Email 2 -> wait -> Email 3 -> ... +``` + +With proper async/parallel processing, throughput could be 10-20x higher. + +--- + +#### 3.4 MEDIUM: No Dynamic Message Center Integration + +The system name is "M365_Messagecenter" but there's **zero integration** with the actual Microsoft 365 Message Center API. + +All content is hardcoded: +```powershell +'CARD1_TITLE' = "New Teams Features" +'CARD1_CONTENT' = "Microsoft Teams introduces new collaboration features..." +``` + +This is a manual newsletter system, not an automated M365 digest. + +--- + +### 4. OPERATIONAL GAPS + +#### 4.1 CRITICAL: No Logging Infrastructure + +- No log levels (DEBUG, INFO, WARN, ERROR) +- No log rotation +- No structured logging (JSON) +- No correlation IDs for tracking +- No centralized logging support (Splunk, ELK, Azure Monitor) + +--- + +#### 4.2 HIGH: No Metrics/Telemetry + +- No send success/failure counters +- No latency measurements +- No batch timing metrics +- No dashboard/alerting integration + +--- + +#### 4.3 HIGH: No Bounce Handling + +When emails bounce (invalid address, mailbox full, etc.): +- No webhook integration +- No bounce parsing +- No automatic list cleanup +- No suppression list management + +--- + +#### 4.4 MEDIUM: No Scheduling Integration + +- No Windows Task Scheduler wrapper +- No Azure Automation runbook +- No cron-style scheduling +- No run-once prevention (could accidentally run twice) + +--- + +### 5. CODE QUALITY ISSUES + +#### 5.1 MEDIUM: Inconsistent Error Handling + +**File:** `M365DigestEmailModule.psm1` + +Some functions throw: +```powershell +throw "Failed to acquire OAuth token: $($_.Exception.Message)" # Line 91 +``` + +Others return boolean: +```powershell +return $false # Line 315 +``` + +Others write warnings: +```powershell +Write-Warning "Attachment not found, skipping: $attachmentPath" # Line 297 +``` + +No consistent error handling pattern. + +--- + +#### 5.2 MEDIUM: Magic Numbers + +**File:** `M365DigestEmailModule.psm1:464` +```powershell +Start-Sleep -Milliseconds (Get-Random -Minimum 150 -Maximum 500) +``` + +Why 150-500ms? Undocumented. Should be configurable constants. + +--- + +#### 5.3 LOW: Duplicate HTML Entity Loading + +**File:** `M365DigestEmailModule.psm1:130` +```powershell +Add-Type -AssemblyName System.Web +``` + +Called inside `Get-ProcessedHtmlTemplate` which is called for every recipient. Assembly loading is idempotent but wasteful. + +--- + +#### 5.4 LOW: No Module Manifest + +The module has no `.psd1` manifest file: +- No version tracking +- No dependency declaration +- No minimum PowerShell version enforcement +- Can't be published to PowerShell Gallery + +--- + +### 6. TEMPLATE & UX ISSUES + +#### 6.1 HIGH: Fixed 3-Card Layout + +**File:** `M365_Digest_Template.htm` + +Template is hardcoded for exactly 3 cards. Cannot: +- Send 1 card (minimal update) +- Send 5 cards (busy month) +- Send 0 cards (no updates) + +--- + +#### 6.2 HIGH: No Mobile Responsiveness Testing + +Template uses fixed 800px width: +```html + +``` + +While `viewport` meta tag exists, no actual responsive design. Email will render poorly on mobile. + +--- + +#### 6.3 MEDIUM: Images Are MASSIVE + +``` +exchange_icon.png 1.6 MB +m365_icon.png 1.6 MB +sharepoint_icon.png 1.6 MB +``` + +Each email carries **4.8MB of images** as base64 attachments. For 10,000 recipients, that's **48GB of data transfer**. + +These should be: +- Compressed (should be <50KB each) +- Or hosted externally and referenced by URL + +--- + +#### 6.4 MEDIUM: No Dark Mode Support + +Modern email clients (Outlook, Gmail) have dark mode. Template has no dark mode CSS, will look broken. + +--- + +### 7. COMPLIANCE GAPS + +#### 7.1 HIGH: GDPR/CAN-SPAM Non-Compliance + +- **Unsubscribe:** Placeholder only (`UNSUBSCRIBE_LINK`), no backend +- **Preference Center:** None +- **Physical Address:** Footer is empty (`
` on line 119-121 is blank) +- **Sender Identification:** Minimal +- **Opt-out Processing:** None - manual list management required + +--- + +#### 7.2 MEDIUM: No Consent Tracking + +- No record of when recipients opted in +- No consent audit trail +- No double opt-in support + +--- + +### 8. DOCUMENTATION ISSUES + +#### 8.1 The Documentation Lies + +Multiple references to non-existent features: +- `Send-M365Digest-OAuth.ps1` - doesn't exist +- Azure Key Vault integration - mentioned but not implemented +- Windows Credential Manager - mentioned but not implemented + +--- + +#### 8.2 Example Credentials in Docs + +**File:** `Send-M365Digest-BasicAuth.ps1:75-76` +```powershell +$smtpUsername = "adm_huebener@elkw.de" # Real email exposed +``` + +--- + +## PART 2: EXPANDED IDEAS & ENHANCEMENTS + +### Enhancement 1: Microsoft Graph API Integration + +**Current State:** Legacy SMTP with workaround OAuth +**Target State:** Native Graph API integration + +```powershell +# Vision: Native Graph API sending +$graphEndpoint = "https://graph.microsoft.com/v1.0/users/{sender}/sendMail" + +$mailBody = @{ + message = @{ + subject = $Subject + body = @{ + contentType = "HTML" + content = $HtmlBody + } + toRecipients = @( + @{ emailAddress = @{ address = $To } } + ) + } +} + +Invoke-RestMethod -Uri $graphEndpoint -Method POST -Body ($mailBody | ConvertTo-Json -Depth 10) -Headers @{ + Authorization = "Bearer $AccessToken" + 'Content-Type' = 'application/json' +} +``` + +**Benefits:** +- Modern, supported API +- Built-in delivery tracking +- Larger attachment limits +- Better error messages +- No SMTP configuration needed + +--- + +### Enhancement 2: M365 Message Center API Integration + +**The name says it all - actually pull from Message Center!** + +```powershell +# Vision: Auto-pull Message Center updates +function Get-M365MessageCenterUpdates { + param( + [int]$DaysBack = 30, + [string[]]$Services = @('Teams', 'Exchange', 'SharePoint') + ) + + $endpoint = "https://graph.microsoft.com/v1.0/admin/serviceAnnouncement/messages" + $filter = "startDateTime ge $((Get-Date).AddDays(-$DaysBack).ToString('yyyy-MM-ddTHH:mm:ssZ'))" + + $messages = Invoke-GraphRequest -Uri "$endpoint`?`$filter=$filter" + + return $messages.value | Where-Object { + $_.services -match ($Services -join '|') + } | Select-Object -First 5 # Top 5 updates +} +``` + +**Auto-generated digest content instead of manual entry!** + +--- + +### Enhancement 3: Intelligent Template Engine + +```powershell +# Vision: Handlebars-style dynamic templates +$template = @" +{{#each cards}} +
+

{{title}}

+

{{content}}

+ {{#if link}}Read more{{/if}} +
+{{/each}} + +{{#if hasUpdates}} +

You have {{updateCount}} new updates.

+{{else}} +

No new updates this period.

+{{/if}} +"@ + +# Support variable number of cards (0-N) +# Conditional sections +# Loops +# Filters +``` + +--- + +### Enhancement 4: Parallel Async Sending with Runspace Pools + +```powershell +# Vision: 10x throughput with parallel sending +function Send-BulkEmailParallel { + param( + [array]$Recipients, + [int]$MaxConcurrency = 10 + ) + + $runspacePool = [runspacefactory]::CreateRunspacePool(1, $MaxConcurrency) + $runspacePool.Open() + + $jobs = $Recipients | ForEach-Object { + $powerShell = [powershell]::Create() + $powerShell.RunspacePool = $runspacePool + + [void]$powerShell.AddScript({ + param($Recipient, $Config) + Send-HtmlEmail @Config -To $Recipient.Email + }).AddArgument($_).AddArgument($emailConfig) + + @{ + PowerShell = $powerShell + Handle = $powerShell.BeginInvoke() + Recipient = $_ + } + } + + # Collect results... +} +``` + +--- + +### Enhancement 5: Comprehensive Observability Stack + +```powershell +# Vision: Structured logging with correlation +function Write-DigestLog { + param( + [string]$Level, + [string]$Message, + [string]$CorrelationId, + [hashtable]$Properties + ) + + $logEntry = @{ + timestamp = Get-Date -Format 'o' + level = $Level + message = $Message + correlationId = $CorrelationId + properties = $Properties + hostname = $env:COMPUTERNAME + processId = $PID + } + + # Output to multiple sinks + $json = $logEntry | ConvertTo-Json -Compress + + # File + Add-Content -Path $LogFile -Value $json + + # Application Insights (if configured) + if ($AppInsightsKey) { + Send-AppInsightsTrace -Message $Message -Properties $Properties + } + + # Console (colorized) + switch ($Level) { + 'ERROR' { Write-Host $Message -ForegroundColor Red } + 'WARN' { Write-Host $Message -ForegroundColor Yellow } + 'INFO' { Write-Host $Message -ForegroundColor Green } + 'DEBUG' { Write-Host $Message -ForegroundColor Gray } + } +} +``` + +--- + +### Enhancement 6: Bounce & Delivery Tracking + +```powershell +# Vision: Track delivery status via Graph API +function Get-EmailDeliveryStatus { + param([string]$MessageId) + + # Query message trace + $endpoint = "https://graph.microsoft.com/v1.0/reports/getEmailActivityDetail" + + # Or use Exchange Message Trace + $trace = Get-MessageTrace -MessageId $MessageId -StartDate (Get-Date).AddHours(-24) + + return @{ + Status = $trace.Status # Delivered, Failed, Pending + Details = $trace.Detail + Recipient = $trace.RecipientAddress + } +} + +# Automatic bounce processing +function Process-BouncedEmails { + $bounces = Get-MessageTrace -Status Failed -StartDate (Get-Date).AddDays(-1) + + foreach ($bounce in $bounces) { + # Add to suppression list + Add-SuppressionListEntry -Email $bounce.RecipientAddress -Reason $bounce.Detail + + # Remove from active recipients + Remove-RecipientFromList -Email $bounce.RecipientAddress + + # Log for audit + Write-DigestLog -Level WARN -Message "Bounced: $($bounce.RecipientAddress)" -Properties @{ + reason = $bounce.Detail + originalMessageId = $bounce.MessageId + } + } +} +``` + +--- + +### Enhancement 7: Azure-Native Architecture + +``` +┌────────────────────────────────────────────────────────────────┐ +│ Azure-Native M365 Digest │ +├────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │ +│ │ Azure │ │ Azure │ │ Microsoft │ │ +│ │ Key Vault │────▶│ Function │────▶│ Graph API │ │ +│ │ (secrets) │ │ (trigger) │ │ (send mail) │ │ +│ └─────────────┘ └──────────────┘ └─────────────────┘ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ ┌──────────────┐ │ │ +│ │ │ Azure │ │ │ +│ │ │ Blob/Table │ │ │ +│ │ │ (recipients) │ │ │ +│ │ └──────────────┘ │ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ Application Insights │ │ +│ │ (logging, metrics, dashboards) │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ │ +└────────────────────────────────────────────────────────────────┘ +``` + +--- + +### Enhancement 8: Subscription Management System + +```powershell +# Vision: Full preference center backend +class SubscriptionManager { + [string]$StorageConnectionString + + [void] Subscribe([string]$Email, [string]$Source) { + $record = @{ + email = $Email + subscribedAt = Get-Date + source = $Source + status = 'Active' + preferences = @{ + frequency = 'Monthly' + categories = @('Teams', 'Exchange', 'SharePoint') + } + } + $this.SaveToStorage($record) + } + + [void] Unsubscribe([string]$Email, [string]$Reason) { + $record = $this.GetSubscription($Email) + $record.status = 'Unsubscribed' + $record.unsubscribedAt = Get-Date + $record.unsubscribeReason = $Reason + $this.SaveToStorage($record) + } + + [void] UpdatePreferences([string]$Email, [hashtable]$Preferences) { + $record = $this.GetSubscription($Email) + $record.preferences = $Preferences + $record.preferencesUpdatedAt = Get-Date + $this.SaveToStorage($record) + } + + [array] GetActiveSubscribers([string[]]$Categories) { + return $this.Query("status eq 'Active'") | Where-Object { + ($_.preferences.categories | Where-Object { $_ -in $Categories }).Count -gt 0 + } + } +} +``` + +--- + +### Enhancement 9: A/B Testing Framework + +```powershell +# Vision: Test different subject lines, content variations +function Send-ABTestCampaign { + param( + [array]$Recipients, + [array]$Variants # Different subject lines, templates, etc. + ) + + # Split recipients into test groups + $groups = Split-Recipients -Recipients $Recipients -GroupCount $Variants.Count + + $results = @{} + + for ($i = 0; $i -lt $Variants.Count; $i++) { + $variant = $Variants[$i] + $group = $groups[$i] + + # Send with tracking + $campaignId = New-Guid + Send-BulkHtmlEmail -Recipients $group -TemplateConfig $variant -TrackingId $campaignId + + $results[$variant.Name] = @{ + CampaignId = $campaignId + RecipientCount = $group.Count + SentAt = Get-Date + } + } + + # After 24-48 hours, analyze: + # - Open rates (if pixel tracking enabled) + # - Click rates (if link tracking enabled) + # - Reply rates + + return $results +} +``` + +--- + +### Enhancement 10: Multi-Language Support + +```powershell +# Vision: Localized email content +$translations = @{ + 'en-US' = @{ + subject = "Microsoft 365 Monthly Digest" + greeting = "Hello {0}," + readMore = "Read more" + unsubscribe = "Unsubscribe from this newsletter" + } + 'de-DE' = @{ + subject = "Microsoft 365 Monatlicher Digest" + greeting = "Hallo {0}," + readMore = "Weiterlesen" + unsubscribe = "Von diesem Newsletter abmelden" + } + 'fr-FR' = @{ + subject = "Résumé mensuel Microsoft 365" + greeting = "Bonjour {0}," + readMore = "En savoir plus" + unsubscribe = "Se désabonner de cette newsletter" + } +} + +function Get-LocalizedContent { + param( + [string]$Key, + [string]$Locale = 'en-US', + [object[]]$FormatArgs + ) + + $content = $translations[$Locale][$Key] ?? $translations['en-US'][$Key] + + if ($FormatArgs) { + return $content -f $FormatArgs + } + return $content +} +``` + +--- + +## PART 3: INTEGRATION PLAN + +### Phase 1: Critical Security Fixes (Week 1-2) + +| Priority | Task | Effort | Risk if Skipped | +|----------|------|--------|-----------------| +| P0 | Remove hardcoded credentials from all files | 2h | Credential leak | +| P0 | Implement SecureString credential handling | 4h | Credential exposure | +| P0 | Add Windows Credential Manager integration | 4h | Manual credential management | +| P0 | Create the missing Send-M365Digest-OAuth.ps1 | 8h | Documentation lies | +| P1 | Add email address validation | 4h | Script crashes | +| P1 | Add URL validation for template links | 4h | Potential phishing | +| P1 | Implement failed email log file | 4h | No failure tracking | + +**Deliverables:** +- [ ] No plaintext credentials in source code +- [ ] Working OAuth script that matches documentation +- [ ] Input validation on all user-provided data +- [ ] `failed_recipients.csv` generated for permanent failures + +--- + +### Phase 2: Operational Maturity (Week 3-4) + +| Priority | Task | Effort | Benefit | +|----------|------|--------|---------| +| P1 | Implement structured JSON logging | 8h | Debugging, auditing | +| P1 | Add correlation IDs to all operations | 4h | Trace individual emails | +| P1 | Create PowerShell module manifest (.psd1) | 2h | Version control, publishing | +| P1 | Implement atomic checkpoint writes | 4h | Crash resilience | +| P2 | Add Application Insights integration | 8h | Cloud monitoring | +| P2 | Create Pester unit tests | 16h | Regression prevention | + +**Deliverables:** +- [ ] JSON log files with structured data +- [ ] Module version 1.1.0 with manifest +- [ ] Test coverage >60% +- [ ] Azure Monitor dashboard (optional) + +--- + +### Phase 3: Performance & Scalability (Week 5-6) + +| Priority | Task | Effort | Benefit | +|----------|------|--------|---------| +| P1 | Implement SMTP connection reuse | 8h | 2-3x faster sending | +| P1 | Compress icon images (<50KB each) | 2h | 98% bandwidth reduction | +| P2 | Add parallel sending with runspace pools | 16h | 10x throughput | +| P2 | Implement Microsoft Graph API sending | 24h | Modern, supported API | +| P3 | Add database backend option (SQLite) | 16h | Better than CSV | + +**Deliverables:** +- [ ] Connection pooling for batch sends +- [ ] Images optimized (total <200KB) +- [ ] Optional parallel mode flag +- [ ] Graph API as alternative to SMTP + +--- + +### Phase 4: Feature Enhancement (Week 7-8) + +| Priority | Task | Effort | Benefit | +|----------|------|--------|---------| +| P2 | Dynamic template engine (variable cards) | 16h | Flexible content | +| P2 | Message Center API integration | 24h | Automated content | +| P2 | Mobile-responsive email template | 8h | Better UX | +| P2 | Dark mode CSS support | 4h | Modern appearance | +| P3 | Multi-language support | 16h | Global reach | + +**Deliverables:** +- [ ] Templates support 0-N cards +- [ ] Auto-pull updates from M365 Message Center +- [ ] Emails render well on mobile +- [ ] Dark mode compatible + +--- + +### Phase 5: Compliance & Management (Week 9-10) + +| Priority | Task | Effort | Benefit | +|----------|------|--------|---------| +| P1 | Implement unsubscribe backend | 16h | Legal compliance | +| P1 | Add preference center | 24h | User control | +| P2 | Bounce handling automation | 16h | List hygiene | +| P2 | Delivery tracking integration | 16h | Visibility | +| P3 | GDPR consent tracking | 8h | Audit trail | + +**Deliverables:** +- [ ] Working unsubscribe mechanism +- [ ] Preference center (frequency, topics) +- [ ] Automatic bounce processing +- [ ] Delivery status dashboard + +--- + +### Phase 6: Enterprise Features (Week 11-12) + +| Priority | Task | Effort | Benefit | +|----------|------|--------|---------| +| P2 | Azure Automation runbook | 16h | Scheduled execution | +| P2 | Azure Key Vault integration | 8h | Enterprise secrets | +| P3 | A/B testing framework | 24h | Optimization | +| P3 | Analytics dashboard | 24h | Business insights | + +**Deliverables:** +- [ ] One-click Azure deployment +- [ ] Secrets in Key Vault +- [ ] A/B test capability +- [ ] Engagement metrics + +--- + +## Implementation Roadmap Visualization + +``` +Timeline (12 weeks) +══════════════════════════════════════════════════════════════════════ + +Week 1-2: Security Hardening ████████████████░░░░░░░░░░░░░░░░░░░░░░░░░ + - Credentials + - Input validation + - Missing OAuth script + +Week 3-4: Operational Maturity ░░░░░░░░████████████████░░░░░░░░░░░░░░░░ + - Logging + - Testing + - Module manifest + +Week 5-6: Performance ░░░░░░░░░░░░░░░░░░░░████████████████░░░░░░░░░░░░ + - Connection pooling + - Image optimization + - Parallel processing + +Week 7-8: Features ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████████░░░░ + - Dynamic templates + - Message Center API + - Mobile/Dark mode + +Week 9-10: Compliance ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████████████ + - Unsubscribe + - Bounce handling + - Preference center + +Week 11-12: Enterprise ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░████████ + - Azure integration + - A/B testing + - Analytics + +══════════════════════════════════════════════════════════════════════ +MVP (Prod-Ready) │ │ │ +v1.1 v1.2 v1.5 v2.0 +``` + +--- + +## Risk Assessment + +| Risk | Probability | Impact | Mitigation | +|------|-------------|--------|------------| +| Credential leak from current code | HIGH | CRITICAL | Phase 1 fixes | +| OAuth script missing breaks deployment | CERTAIN | HIGH | Create immediately | +| Rate limiting from inefficient sending | MEDIUM | MEDIUM | Phase 3 optimization | +| GDPR non-compliance | MEDIUM | HIGH | Phase 5 compliance | +| Template breaks on mobile | HIGH | LOW | Phase 4 redesign | + +--- + +## Success Metrics + +### Phase 1 Success (Security) +- [ ] Zero credentials in source code +- [ ] All documented features exist +- [ ] Input validation prevents crashes + +### Phase 3 Success (Performance) +- [ ] 5x faster sending than current +- [ ] <500KB total image size +- [ ] Support for 50,000+ recipients + +### Phase 5 Success (Compliance) +- [ ] <1% unsubscribe bounce rate +- [ ] 100% unsubscribe requests honored within 24h +- [ ] Full audit trail for consent + +--- + +## Recommended Immediate Actions + +1. **TODAY:** Remove real email addresses and credentials from source code +2. **THIS WEEK:** Create `Send-M365Digest-OAuth.ps1` to match documentation +3. **THIS WEEK:** Compress image files from 1.6MB to <50KB each +4. **NEXT WEEK:** Implement structured logging +5. **NEXT WEEK:** Add email validation and failure logging + +--- + +## Conclusion + +This codebase has a solid foundation but significant gaps. The most critical issue is the **security posture** - hardcoded credentials and missing OAuth script. The second priority is **operational maturity** - no logging, no testing, no failure tracking makes production use risky. + +With the phased approach above, this can evolve from a proof-of-concept into an enterprise-ready solution. The key is to **not ship to production** until at least Phase 1 and Phase 2 are complete. + +**Estimated Total Effort:** 200-250 hours (12 weeks with 1 developer) + +--- + +*Review completed by Claude Code Analysis - December 15, 2025* diff --git a/M365DigestEmailModule.psd1 b/M365DigestEmailModule.psd1 new file mode 100644 index 0000000..129e876 --- /dev/null +++ b/M365DigestEmailModule.psd1 @@ -0,0 +1,113 @@ +@{ + # Module manifest for M365DigestEmailModule + + # Script module file associated with this manifest + RootModule = 'M365DigestEmailModule.psm1' + + # Version number + ModuleVersion = '1.1.0' + + # Unique ID for this module + GUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' + + # Author + Author = 'Jan Huebener' + + # Company or vendor + CompanyName = 'DATAGROUP' + + # Copyright statement + Copyright = '(c) 2025. All rights reserved.' + + # Description + Description = 'M365 Digest Email Sender - Professional HTML email system with OAuth/Basic Auth, inline images, batching, and checkpoint-based resume capability.' + + # Minimum PowerShell version required + PowerShellVersion = '5.1' + + # Required .NET Framework version + DotNetFrameworkVersion = '4.5' + + # Functions to export from this module + FunctionsToExport = @( + # Logging + 'Set-DigestLogConfig', + 'Write-DigestLog', + + # Validation + 'Test-EmailAddress', + 'Test-UrlSafety', + + # Authentication + 'Get-EmailAuthenticationCredential', + + # Template Processing + 'Get-ProcessedHtmlTemplate', + + # Email Construction + 'New-EmailAlternateViewWithImages', + + # Sending + 'Send-HtmlEmail', + 'Send-BulkHtmlEmail', + + # Tracking + 'Add-FailedRecipient' + ) + + # Cmdlets to export (none - this is a script module) + CmdletsToExport = @() + + # Variables to export (none) + VariablesToExport = @() + + # Aliases to export (none) + AliasesToExport = @() + + # Private data + PrivateData = @{ + PSData = @{ + # Tags for PSGallery + Tags = @('Email', 'SMTP', 'M365', 'OAuth', 'HTML', 'Bulk', 'Newsletter') + + # License URI + LicenseUri = '' + + # Project URI + ProjectUri = 'https://github.com/PSScript/M365_Messagecenter' + + # Release notes + ReleaseNotes = @' +## Version 1.1.0 (2025-12-15) + +### New Features +- Added structured JSON logging with correlation IDs +- Added email validation (RFC 5322 compliant) +- Added URL safety validation (blocks javascript:, data:, etc.) +- Added failed recipients tracking (CSV log file) +- Added OAuth2 authentication script (Send-M365Digest-OAuth.ps1) + +### Security Improvements +- Removed hardcoded credentials from scripts +- Added multiple secure credential loading options: + - Interactive prompt (default) + - Environment variables + - Windows Credential Manager + - DPAPI-encrypted file storage +- URLs in templates are now validated for safety +- Non-URL values are HTML-encoded to prevent XSS + +### Breaking Changes +- Send-HtmlEmail now returns a hashtable instead of boolean +- BasicAuth script now requires credential method parameter + +## Version 1.0.0 (2025-11-07) +- Initial release +- Basic and OAuth authentication +- Inline images with AlternateView +- Batch processing with checkpoints +- Modular architecture +'@ + } + } +} diff --git a/M365DigestEmailModule.psm1 b/M365DigestEmailModule.psm1 index 3599da1..1075030 100644 --- a/M365DigestEmailModule.psm1 +++ b/M365DigestEmailModule.psm1 @@ -10,12 +10,275 @@ - Batched sending with rate limiting - Checkpoint-based resume capability - Comprehensive error handling and retry logic + - Email and URL validation + - Structured JSON logging + - Failed recipients tracking .AUTHOR - Jan Hübener + Jan Huebener .VERSION - 1.0.0 + 1.1.0 #> +# ============================================================================ +# MODULE: Logging Infrastructure +# ============================================================================ + +# Script-level logging configuration +$script:LogConfig = @{ + Enabled = $true + LogFilePath = $null + LogLevel = 'INFO' + JsonFormat = $true + CorrelationId = $null +} + +function Set-DigestLogConfig { + <# + .SYNOPSIS + Configures logging settings for the module + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] + [string]$LogFilePath, + + [Parameter(Mandatory = $false)] + [ValidateSet('DEBUG', 'INFO', 'WARN', 'ERROR')] + [string]$LogLevel = 'INFO', + + [Parameter(Mandatory = $false)] + [bool]$JsonFormat = $true, + + [Parameter(Mandatory = $false)] + [string]$CorrelationId + ) + + $script:LogConfig.LogFilePath = $LogFilePath + $script:LogConfig.LogLevel = $LogLevel + $script:LogConfig.JsonFormat = $JsonFormat + $script:LogConfig.CorrelationId = $CorrelationId ?? [guid]::NewGuid().ToString('N').Substring(0, 8) +} + +function Write-DigestLog { + <# + .SYNOPSIS + Writes a structured log entry + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [ValidateSet('DEBUG', 'INFO', 'WARN', 'ERROR')] + [string]$Level, + + [Parameter(Mandatory = $true)] + [string]$Message, + + [Parameter(Mandatory = $false)] + [hashtable]$Properties = @{}, + + [Parameter(Mandatory = $false)] + [string]$Email + ) + + $levelOrder = @{ 'DEBUG' = 0; 'INFO' = 1; 'WARN' = 2; 'ERROR' = 3 } + if ($levelOrder[$Level] -lt $levelOrder[$script:LogConfig.LogLevel]) { + return + } + + $timestamp = Get-Date -Format 'o' + $correlationId = $script:LogConfig.CorrelationId + + $logEntry = @{ + timestamp = $timestamp + level = $Level + message = $Message + correlationId = $correlationId + properties = $Properties + } + if ($Email) { $logEntry.email = $Email } + + # File logging + if ($script:LogConfig.LogFilePath) { + try { + $logDir = Split-Path $script:LogConfig.LogFilePath -Parent + if ($logDir -and -not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null + } + + if ($script:LogConfig.JsonFormat) { + $logLine = $logEntry | ConvertTo-Json -Compress + } + else { + $logLine = "$timestamp [$Level] [$correlationId] $Message" + } + Add-Content -LiteralPath $script:LogConfig.LogFilePath -Value $logLine -Encoding UTF8 + } + catch { + Write-Warning "Log write failed: $($_.Exception.Message)" + } + } +} + +# ============================================================================ +# MODULE: Validation Functions +# ============================================================================ + +function Test-EmailAddress { + <# + .SYNOPSIS + Validates an email address format + .PARAMETER Email + Email address to validate + .OUTPUTS + Returns validation result object with IsValid, Email, and Error properties + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Email + ) + + $result = @{ + IsValid = $false + Email = $Email.Trim() + Error = $null + } + + $email = $Email.Trim() + + if ([string]::IsNullOrWhiteSpace($email)) { + $result.Error = "Email is empty" + return [PSCustomObject]$result + } + + if ($email.Length -gt 254) { + $result.Error = "Email exceeds 254 characters" + return [PSCustomObject]$result + } + + # Standard email regex + $pattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + + if ($email -notmatch $pattern) { + $result.Error = "Invalid email format" + return [PSCustomObject]$result + } + + $parts = $email -split '@' + if ($parts[0].Length -gt 64) { + $result.Error = "Local part exceeds 64 characters" + return [PSCustomObject]$result + } + + $result.IsValid = $true + return [PSCustomObject]$result +} + +function Test-UrlSafety { + <# + .SYNOPSIS + Validates a URL for safety (blocks javascript:, data:, etc.) + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$Url + ) + + $result = @{ + IsValid = $false + Url = $Url + Error = $null + } + + if ([string]::IsNullOrWhiteSpace($Url)) { + $result.Error = "URL is empty" + return [PSCustomObject]$result + } + + $dangerousSchemes = @('javascript:', 'vbscript:', 'data:', 'file:', 'about:') + $lowerUrl = $Url.ToLower().Trim() + + foreach ($scheme in $dangerousSchemes) { + if ($lowerUrl.StartsWith($scheme)) { + $result.Error = "Dangerous URL scheme: $scheme" + return [PSCustomObject]$result + } + } + + if (-not ($lowerUrl.StartsWith('http://') -or $lowerUrl.StartsWith('https://'))) { + $result.Error = "URL must use http:// or https://" + return [PSCustomObject]$result + } + + try { + $uri = [System.Uri]::new($Url) + if ([string]::IsNullOrWhiteSpace($uri.Host)) { + $result.Error = "URL has no valid host" + return [PSCustomObject]$result + } + } + catch { + $result.Error = "Malformed URL" + return [PSCustomObject]$result + } + + $result.IsValid = $true + return [PSCustomObject]$result +} + +# ============================================================================ +# MODULE: Failed Recipients Tracking +# ============================================================================ + +function Add-FailedRecipient { + <# + .SYNOPSIS + Records a failed email recipient to the failure log + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Email, + + [Parameter(Mandatory = $true)] + [string]$Reason, + + [Parameter(Mandatory = $true)] + [string]$FailedRecipientsPath, + + [Parameter(Mandatory = $false)] + [int]$AttemptCount = 0 + ) + + try { + $failDir = Split-Path $FailedRecipientsPath -Parent + if ($failDir -and -not (Test-Path $failDir)) { + New-Item -Path $failDir -ItemType Directory -Force | Out-Null + } + + if (-not (Test-Path $FailedRecipientsPath)) { + "timestamp;email;reason;attempts;correlationId" | Out-File -FilePath $FailedRecipientsPath -Encoding UTF8 + } + + $timestamp = Get-Date -Format 'o' + $correlationId = $script:LogConfig.CorrelationId + $escapedReason = $Reason -replace ';', ',' + + $line = "$timestamp;$Email;$escapedReason;$AttemptCount;$correlationId" + Add-Content -LiteralPath $FailedRecipientsPath -Value $line -Encoding UTF8 + + Write-DigestLog -Level WARN -Message "Failed recipient recorded" -Email $Email -Properties @{ + reason = $Reason + attempts = $AttemptCount + } + } + catch { + Write-Warning "Failed to write to failed recipients file: $($_.Exception.Message)" + } +} + # ============================================================================ # MODULE: Email Authentication # ============================================================================ @@ -61,16 +324,18 @@ function Get-EmailAuthenticationCredential { switch ($AuthMethod) { 'Basic' { + Write-DigestLog -Level DEBUG -Message "Creating Basic Authentication credential" -Properties @{ username = $Username } Write-Verbose "Creating Basic Authentication credential for $Username" $securePass = ConvertTo-SecureString $Password -AsPlainText -Force return New-Object System.Management.Automation.PSCredential($Username, $securePass) } - + 'OAuth' { + Write-DigestLog -Level DEBUG -Message "Acquiring OAuth2 token" -Properties @{ username = $Username; tenantId = $TenantId } Write-Verbose "Acquiring OAuth2 token for $Username" try { $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" - + $body = @{ client_id = $ClientId client_secret = $ClientSecret @@ -79,16 +344,18 @@ function Get-EmailAuthenticationCredential { } $response = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body $body -ContentType "application/x-www-form-urlencoded" - - # For OAuth, we use the access token as password with XOAUTH2 + $oauthToken = $response.access_token $secureToken = ConvertTo-SecureString $oauthToken -AsPlainText -Force - + + Write-DigestLog -Level INFO -Message "OAuth token acquired" -Properties @{ expiresIn = $response.expires_in } Write-Verbose "OAuth token acquired successfully" return New-Object System.Management.Automation.PSCredential($Username, $secureToken) } catch { - throw "Failed to acquire OAuth token: $($_.Exception.Message)" + $errorMsg = "Failed to acquire OAuth token: $($_.Exception.Message)" + Write-DigestLog -Level ERROR -Message $errorMsg + throw $errorMsg } } } @@ -108,6 +375,8 @@ function Get-ProcessedHtmlTemplate { Hashtable of placeholder-value pairs for replacement .PARAMETER Encoding File encoding (default: UTF8) + .PARAMETER ValidateUrls + Validate URLs in replacements (default: $true) #> [CmdletBinding()] param( @@ -119,26 +388,53 @@ function Get-ProcessedHtmlTemplate { [hashtable]$Replacements = @{}, [Parameter(Mandatory = $false)] - [string]$Encoding = 'UTF8' + [string]$Encoding = 'UTF8', + + [Parameter(Mandatory = $false)] + [bool]$ValidateUrls = $true ) try { + Write-DigestLog -Level DEBUG -Message "Loading HTML template" -Properties @{ path = $TemplatePath } Write-Verbose "Loading HTML template from: $TemplatePath" $htmlContent = Get-Content -LiteralPath $TemplatePath -Raw -Encoding $Encoding - # HTML-encode all replacement values to prevent injection - Add-Type -AssemblyName System.Web + if (-not ([System.Management.Automation.PSTypeName]'System.Web.HttpUtility').Type) { + Add-Type -AssemblyName System.Web + } + foreach ($key in $Replacements.Keys) { $value = $Replacements[$key] - $encodedValue = [System.Web.HttpUtility]::HtmlEncode($value) - $htmlContent = $htmlContent.Replace($key, $encodedValue) + + # URL placeholders - validate but don't HTML encode + if ($key -match 'LINK|URL|HREF') { + if ($ValidateUrls) { + $urlCheck = Test-UrlSafety -Url $value + if (-not $urlCheck.IsValid) { + Write-DigestLog -Level WARN -Message "Invalid URL replaced with #" -Properties @{ + placeholder = $key + error = $urlCheck.Error + } + $value = "#" + } + } + $htmlContent = $htmlContent.Replace($key, $value) + } + else { + # HTML encode non-URL values + $encodedValue = [System.Web.HttpUtility]::HtmlEncode($value) + $htmlContent = $htmlContent.Replace($key, $encodedValue) + } } + Write-DigestLog -Level DEBUG -Message "Template processed" -Properties @{ replacements = $Replacements.Count } Write-Verbose "Template processed with $($Replacements.Count) replacements" return $htmlContent } catch { - throw "Failed to process HTML template: $($_.Exception.Message)" + $errorMsg = "Failed to process HTML template: $($_.Exception.Message)" + Write-DigestLog -Level ERROR -Message $errorMsg + throw $errorMsg } } @@ -154,7 +450,6 @@ function New-EmailAlternateViewWithImages { HTML content as string .PARAMETER InlineImages Array of hashtables with ContentId and FilePath - Example: @{ ContentId = 'logo1'; FilePath = 'C:\temp\logo.png' } #> [CmdletBinding()] param( @@ -166,38 +461,42 @@ function New-EmailAlternateViewWithImages { ) try { - # Create AlternateView for HTML body $altView = [System.Net.Mail.AlternateView]::CreateAlternateViewFromString( $HtmlBody, [System.Text.Encoding]::UTF8, "text/html" ) - # Add inline images as LinkedResources foreach ($image in $InlineImages) { if (-not (Test-Path $image.FilePath)) { + Write-DigestLog -Level WARN -Message "Inline image not found" -Properties @{ + contentId = $image.ContentId + path = $image.FilePath + } Write-Warning "Inline image not found: $($image.FilePath)" continue } $linkedResource = New-Object System.Net.Mail.LinkedResource($image.FilePath) - - # Determine MIME type from extension + $extension = [System.IO.Path]::GetExtension($image.FilePath).TrimStart('.').ToLower() if ($extension -eq 'jpg') { $extension = 'jpeg' } - + $linkedResource.ContentType = New-Object System.Net.Mime.ContentType("image/$extension") $linkedResource.ContentId = $image.ContentId $linkedResource.TransferEncoding = [System.Net.Mime.TransferEncoding]::Base64 [void]$altView.LinkedResources.Add($linkedResource) - Write-Verbose "Added inline image: $($image.ContentId) from $($image.FilePath)" + Write-DigestLog -Level DEBUG -Message "Added inline image" -Properties @{ contentId = $image.ContentId } + Write-Verbose "Added inline image: $($image.ContentId)" } return $altView } catch { - throw "Failed to create AlternateView with images: $($_.Exception.Message)" + $errorMsg = "Failed to create AlternateView: $($_.Exception.Message)" + Write-DigestLog -Level ERROR -Message $errorMsg + throw $errorMsg } } @@ -209,28 +508,6 @@ function Send-HtmlEmail { <# .SYNOPSIS Sends HTML email with inline images and attachments - .PARAMETER To - Recipient email address - .PARAMETER From - Sender email address - .PARAMETER Subject - Email subject - .PARAMETER HtmlBody - HTML body content - .PARAMETER InlineImages - Array of hashtables with inline image definitions - .PARAMETER Attachments - Array of file paths to attach - .PARAMETER Bcc - BCC recipient(s) - .PARAMETER SmtpServer - SMTP server address - .PARAMETER SmtpPort - SMTP server port - .PARAMETER Credential - PSCredential object for authentication - .PARAMETER EnableSsl - Enable SSL/TLS (default: $true) #> [CmdletBinding()] param( @@ -265,14 +542,30 @@ function Send-HtmlEmail { [System.Management.Automation.PSCredential]$Credential, [Parameter(Mandatory = $false)] - [bool]$EnableSsl = $true + [bool]$EnableSsl = $true, + + [Parameter(Mandatory = $false)] + [bool]$ValidateRecipient = $true ) + # Validate recipient + if ($ValidateRecipient) { + $validation = Test-EmailAddress -Email $To + if (-not $validation.IsValid) { + Write-DigestLog -Level WARN -Message "Invalid recipient" -Email $To -Properties @{ error = $validation.Error } + return @{ + Success = $false + Error = "Invalid email: $($validation.Error)" + Email = $To + } + } + $To = $validation.Email + } + $mailMessage = $null $smtpClient = $null try { - # Create mail message $mailMessage = New-Object System.Net.Mail.MailMessage $mailMessage.From = $From $mailMessage.To.Add($To) @@ -282,11 +575,9 @@ function Send-HtmlEmail { $mailMessage.BodyEncoding = [System.Text.Encoding]::UTF8 $mailMessage.IsBodyHtml = $true - # Create and add AlternateView with inline images $altView = New-EmailAlternateViewWithImages -HtmlBody $HtmlBody -InlineImages $InlineImages [void]$mailMessage.AlternateViews.Add($altView) - # Add attachments foreach ($attachmentPath in $Attachments) { if (Test-Path $attachmentPath) { $attachment = New-Object System.Net.Mail.Attachment($attachmentPath) @@ -294,28 +585,37 @@ function Send-HtmlEmail { Write-Verbose "Added attachment: $attachmentPath" } else { - Write-Warning "Attachment not found, skipping: $attachmentPath" + Write-Warning "Attachment not found: $attachmentPath" } } - # Create SMTP client $smtpClient = New-Object System.Net.Mail.SmtpClient($SmtpServer, $SmtpPort) $smtpClient.EnableSsl = $EnableSsl $smtpClient.Credentials = $Credential - # Send email + Write-DigestLog -Level DEBUG -Message "Sending email" -Email $To Write-Verbose "Sending email to: $To" $smtpClient.Send($mailMessage) + Write-DigestLog -Level INFO -Message "Email sent" -Email $To Write-Verbose "Email sent successfully to: $To" - return $true + return @{ + Success = $true + Error = $null + Email = $To + } } catch { - Write-Warning "Failed to send email to ${To}: $($_.Exception.Message)" - return $false + $errorMsg = $_.Exception.Message + Write-DigestLog -Level WARN -Message "Send failed" -Email $To -Properties @{ error = $errorMsg } + Write-Warning "Failed to send to ${To}: $errorMsg" + return @{ + Success = $false + Error = $errorMsg + Email = $To + } } finally { - # Cleanup if ($mailMessage) { $mailMessage.Dispose() } if ($smtpClient) { $smtpClient.Dispose() } } @@ -329,20 +629,6 @@ function Send-BulkHtmlEmail { <# .SYNOPSIS Sends bulk HTML emails with batching, rate limiting, and checkpointing - .PARAMETER Recipients - Array of recipient objects with email and replacement data - .PARAMETER TemplateConfig - Hashtable with template configuration - .PARAMETER SmtpConfig - Hashtable with SMTP configuration - .PARAMETER BatchSize - Number of emails per batch window - .PARAMETER WindowMinutes - Minutes between batch windows - .PARAMETER MaxRetries - Maximum retry attempts per email (default: 3) - .PARAMETER CheckpointPath - Path to checkpoint file for resume capability #> [CmdletBinding()] param( @@ -365,124 +651,217 @@ function Send-BulkHtmlEmail { [int]$MaxRetries = 3, [Parameter(Mandatory = $false)] - [string]$CheckpointPath = "C:\temp\email_checkpoint.txt" + [string]$CheckpointPath = "email_checkpoint.txt", + + [Parameter(Mandatory = $false)] + [string]$FailedRecipientsPath = "failed_recipients.csv", + + [Parameter(Mandatory = $false)] + [string]$LogFilePath ) + # Initialize logging + $correlationId = [guid]::NewGuid().ToString('N').Substring(0, 8) + Set-DigestLogConfig -LogFilePath $LogFilePath -CorrelationId $correlationId + + Write-DigestLog -Level INFO -Message "Starting bulk campaign" -Properties @{ + totalRecipients = $Recipients.Count + batchSize = $BatchSize + } + # Load checkpoint $sentEmails = New-Object System.Collections.Generic.HashSet[string] if (Test-Path $CheckpointPath) { Get-Content $CheckpointPath | ForEach-Object { - [void]$sentEmails.Add($_.Trim()) + [void]$sentEmails.Add($_.Trim().ToLower()) + } + Write-DigestLog -Level INFO -Message "Checkpoint loaded" -Properties @{ alreadySent = $sentEmails.Count } + Write-Host "Loaded checkpoint: $($sentEmails.Count) already sent" + } + + # Filter and validate recipients + $pendingRecipients = @() + $skippedInvalid = 0 + + foreach ($recipient in $Recipients) { + $email = $recipient.Email.Trim() + + if ([string]::IsNullOrWhiteSpace($email)) { continue } + if ($sentEmails.Contains($email.ToLower())) { continue } + + $validation = Test-EmailAddress -Email $email + if (-not $validation.IsValid) { + $skippedInvalid++ + Add-FailedRecipient -Email $email -Reason "Invalid: $($validation.Error)" -FailedRecipientsPath $FailedRecipientsPath + continue } - Write-Host "Loaded checkpoint: $($sentEmails.Count) emails already sent" + + $pendingRecipients += $recipient } - # Filter recipients - $pendingRecipients = $Recipients | Where-Object { - $email = $_.Email.Trim() - -not [string]::IsNullOrWhiteSpace($email) -and -not $sentEmails.Contains($email) + if ($skippedInvalid -gt 0) { + Write-DigestLog -Level WARN -Message "Invalid emails skipped" -Properties @{ count = $skippedInvalid } } if ($pendingRecipients.Count -eq 0) { - Write-Host "No pending emails to send (all recipients already processed or list empty)" - return + Write-DigestLog -Level INFO -Message "No pending emails" + Write-Host "No pending emails to send" + return @{ + CampaignId = $correlationId + Sent = 0 + Failed = $skippedInvalid + } } Write-Host "`n=== BULK EMAIL CAMPAIGN ===" -ForegroundColor Cyan + Write-Host "Campaign ID: $correlationId" Write-Host "Total pending: $($pendingRecipients.Count)" + Write-Host "Already sent: $($sentEmails.Count)" Write-Host "Batch size: $BatchSize" - Write-Host "Window interval: $WindowMinutes minutes" - Write-Host "Max retries: $MaxRetries" + Write-Host "Window: $WindowMinutes min" Write-Host "===========================`n" -ForegroundColor Cyan - # Load base HTML template + # Load template $baseHtml = Get-Content -LiteralPath $TemplateConfig.TemplatePath -Raw -Encoding $TemplateConfig.Encoding - # Stopwatch for timing + # Ensure System.Web is loaded + if (-not ([System.Management.Automation.PSTypeName]'System.Web.HttpUtility').Type) { + Add-Type -AssemblyName System.Web + } + + $stats = @{ Sent = 0; Failed = 0; Retried = 0 } $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() - # Process in batches + # Process batches for ($offset = 0; $offset -lt $pendingRecipients.Count; $offset += $BatchSize) { $windowStart = $stopwatch.Elapsed $endIndex = [Math]::Min($offset + $BatchSize - 1, $pendingRecipients.Count - 1) $batch = $pendingRecipients[$offset..$endIndex] - Write-Host ("[{0:HH:mm:ss}] === Batch {1}-{2} of {3} ===" -f (Get-Date), ($offset + 1), ($endIndex + 1), $pendingRecipients.Count) -ForegroundColor Yellow + $batchNum = [Math]::Floor($offset / $BatchSize) + 1 + $totalBatches = [Math]::Ceiling($pendingRecipients.Count / $BatchSize) + + Write-Host ("[{0:HH:mm:ss}] === Batch {1}/{2} ===" -f (Get-Date), $batchNum, $totalBatches) -ForegroundColor Yellow foreach ($recipient in $batch) { $email = $recipient.Email.Trim() - - # Process template with recipient-specific replacements + + # Process template $htmlBody = $baseHtml foreach ($key in $recipient.Replacements.Keys) { - $value = [System.Web.HttpUtility]::HtmlEncode($recipient.Replacements[$key]) - $htmlBody = $htmlBody.Replace($key, $value) + $value = $recipient.Replacements[$key] + + if ($key -match 'LINK|URL|HREF') { + $urlCheck = Test-UrlSafety -Url $value + if (-not $urlCheck.IsValid) { $value = "#" } + $htmlBody = $htmlBody.Replace($key, $value) + } + else { + $encodedValue = [System.Web.HttpUtility]::HtmlEncode($value) + $htmlBody = $htmlBody.Replace($key, $encodedValue) + } } - # Retry logic + # Retry loop $attempt = 0 $success = $false $retryDelay = 2 + $lastError = $null while ($attempt -lt $MaxRetries -and -not $success) { $attempt++ - + $sendParams = @{ - To = $email - From = $SmtpConfig.From - Subject = $SmtpConfig.Subject - HtmlBody = $htmlBody - InlineImages = $TemplateConfig.InlineImages - Attachments = $TemplateConfig.Attachments - Bcc = $SmtpConfig.Bcc - SmtpServer = $SmtpConfig.Server - SmtpPort = $SmtpConfig.Port - Credential = $SmtpConfig.Credential - EnableSsl = $SmtpConfig.EnableSsl + To = $email + From = $SmtpConfig.From + Subject = $SmtpConfig.Subject + HtmlBody = $htmlBody + InlineImages = $TemplateConfig.InlineImages + Attachments = $TemplateConfig.Attachments + Bcc = $SmtpConfig.Bcc + SmtpServer = $SmtpConfig.Server + SmtpPort = $SmtpConfig.Port + Credential = $SmtpConfig.Credential + EnableSsl = $SmtpConfig.EnableSsl + ValidateRecipient = $false } - $success = Send-HtmlEmail @sendParams + $result = Send-HtmlEmail @sendParams - if ($success) { - # Checkpoint immediately - Add-Content -LiteralPath $CheckpointPath -Value $email - Write-Host " ✓ Sent: $email" -ForegroundColor Green + if ($result.Success) { + $success = $true + $stats.Sent++ + + try { + Add-Content -LiteralPath $CheckpointPath -Value $email -Encoding UTF8 + } + catch { + Write-DigestLog -Level WARN -Message "Checkpoint write failed" -Email $email + } + + Write-Host " [OK] $email" -ForegroundColor Green } else { + $lastError = $result.Error if ($attempt -lt $MaxRetries) { - Write-Warning " ⚠ Retry $attempt/$MaxRetries for $email (waiting ${retryDelay}s)" + $stats.Retried++ + Write-Host " [RETRY $attempt/$MaxRetries] $email" -ForegroundColor Yellow Start-Sleep -Seconds $retryDelay $retryDelay = [Math]::Min($retryDelay * 2, 30) } - else { - Write-Warning " ✗ Permanent failure after $MaxRetries attempts: $email" - } } } - # Small jitter between emails + if (-not $success) { + $stats.Failed++ + Write-Host " [FAIL] $email" -ForegroundColor Red + Add-FailedRecipient -Email $email -Reason $lastError -FailedRecipientsPath $FailedRecipientsPath -AttemptCount $attempt + } + Start-Sleep -Milliseconds (Get-Random -Minimum 150 -Maximum 500) } - # Enforce window spacing - $elapsed = $stopwatch.Elapsed - $windowStart - $targetWindow = [TimeSpan]::FromMinutes($WindowMinutes) - - if ($elapsed -lt $targetWindow) { - $sleepSeconds = [int](($targetWindow - $elapsed).TotalSeconds) - if ($sleepSeconds -gt 0) { - Write-Host "`n⏱ Waiting ${sleepSeconds}s to honor batch window spacing..." -ForegroundColor Cyan - Start-Sleep -Seconds $sleepSeconds + # Window spacing + if ($offset + $BatchSize -lt $pendingRecipients.Count) { + $elapsed = $stopwatch.Elapsed - $windowStart + $targetWindow = [TimeSpan]::FromMinutes($WindowMinutes) + + if ($elapsed -lt $targetWindow) { + $sleepSeconds = [int](($targetWindow - $elapsed).TotalSeconds) + if ($sleepSeconds -gt 0) { + Write-Host "`n[WAIT] ${sleepSeconds}s until next batch..." -ForegroundColor Cyan + Start-Sleep -Seconds $sleepSeconds + } } } Write-Host "" } $stopwatch.Stop() + + Write-DigestLog -Level INFO -Message "Campaign complete" -Properties @{ + sent = $stats.Sent + failed = $stats.Failed + duration = $stopwatch.Elapsed.ToString('hh\:mm\:ss') + } + Write-Host "`n=== CAMPAIGN COMPLETE ===" -ForegroundColor Green - Write-Host "Total time: $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))" - Write-Host "Emails sent: $($pendingRecipients.Count)" + Write-Host "Campaign ID: $correlationId" + Write-Host "Duration: $($stopwatch.Elapsed.ToString('hh\:mm\:ss'))" + Write-Host "Sent: $($stats.Sent)" -ForegroundColor Green + Write-Host "Failed: $($stats.Failed)" -ForegroundColor $(if ($stats.Failed -gt 0) { 'Red' } else { 'Green' }) + if ($stats.Failed -gt 0) { + Write-Host "Failures logged to: $FailedRecipientsPath" -ForegroundColor Yellow + } Write-Host "========================`n" -ForegroundColor Green + + return @{ + CampaignId = $correlationId + Sent = $stats.Sent + Failed = $stats.Failed + Retried = $stats.Retried + Duration = $stopwatch.Elapsed + } } # ============================================================================ @@ -490,9 +869,14 @@ function Send-BulkHtmlEmail { # ============================================================================ Export-ModuleMember -Function @( + 'Set-DigestLogConfig', + 'Write-DigestLog', + 'Test-EmailAddress', + 'Test-UrlSafety', 'Get-EmailAuthenticationCredential', 'Get-ProcessedHtmlTemplate', 'New-EmailAlternateViewWithImages', 'Send-HtmlEmail', - 'Send-BulkHtmlEmail' + 'Send-BulkHtmlEmail', + 'Add-FailedRecipient' ) diff --git a/Send-M365Digest-BasicAuth.ps1 b/Send-M365Digest-BasicAuth.ps1 index 0260b92..54f85de 100644 --- a/Send-M365Digest-BasicAuth.ps1 +++ b/Send-M365Digest-BasicAuth.ps1 @@ -1,23 +1,57 @@ #Requires -Version 5.1 <# .SYNOPSIS - M365 Digest Email Campaign - Basic Authentication Example + M365 Digest Email Campaign - Basic Authentication .DESCRIPTION - Example script demonstrating bulk email sending with: - - Basic SMTP authentication - - CSV data import + Bulk email sending with Basic SMTP authentication featuring: + - Secure credential handling (multiple options) + - CSV data import with validation - Template-based HTML emails - - Inline images (logo + 3 product icons) - - PDF attachments + - Inline images and PDF attachments - Batched sending with rate limiting + - Checkpoint-based resume capability +.PARAMETER ConfigMode + 'Test' for reduced batch size, 'Production' for full campaign +.PARAMETER CredentialMethod + How to obtain SMTP credentials: + - 'Prompt' : Interactive prompt (default, most secure for manual runs) + - 'Environment' : From environment variables + - 'CredentialManager' : From Windows Credential Manager (requires CredentialManager module) + - 'SecureFile' : From encrypted XML file (user-specific DPAPI encryption) +.PARAMETER SmtpUsername + SMTP username (required for Environment method, optional override for others) .NOTES Requires: M365DigestEmailModule.psm1 + + SECURITY: This script does NOT store credentials in source code. + Choose a credential method appropriate for your use case: + - Interactive use: 'Prompt' (default) + - Scheduled tasks: 'SecureFile' or 'CredentialManager' + - CI/CD pipelines: 'Environment' +.AUTHOR + Jan Huebener +.VERSION + 1.1.0 #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] - [string]$ConfigMode = 'Production' # 'Test' or 'Production' + [ValidateSet('Test', 'Production')] + [string]$ConfigMode = 'Production', + + [Parameter(Mandatory = $false)] + [ValidateSet('Prompt', 'Environment', 'CredentialManager', 'SecureFile')] + [string]$CredentialMethod = 'Prompt', + + [Parameter(Mandatory = $false)] + [string]$SmtpUsername, + + [Parameter(Mandatory = $false)] + [string]$SecureFilePath = "$env:USERPROFILE\.m365digest\smtp_credential.xml", + + [Parameter(Mandatory = $false)] + [string]$CredentialTarget = "M365-Digest-SMTP" ) # ============================================================================ @@ -28,10 +62,14 @@ param( $modulePath = Join-Path $PSScriptRoot "M365DigestEmailModule.psm1" Import-Module $modulePath -Force -Verbose -# Paths -$csvPath = "C:\Temp\master_users_all_merged_with_wave4.csv" -$htmlTemplate = "C:\Temp\M365_Digest_Template.htm" -$checkpointFile = "C:\Temp\smtp_send_checkpoint_m365digest.txt" +# ============================================================================ +# PATH CONFIGURATION - UPDATE THESE FOR YOUR ENVIRONMENT +# ============================================================================ + +$csvPath = "C:\Temp\recipients.csv" # Recipient CSV file +$htmlTemplate = Join-Path $PSScriptRoot "M365_Digest_Template.htm" # HTML template +$checkpointFile = "C:\Temp\smtp_send_checkpoint_m365digest.txt" # Checkpoint for resume +$failedRecipientsFile = "C:\Temp\failed_recipients.csv" # Failed emails log # Inline Images (CID must match template references) $inlineImages = @( @@ -41,40 +79,37 @@ $inlineImages = @( }, @{ ContentId = 'm365_icon' - FilePath = 'C:\temp\m365_icon.png' + FilePath = Join-Path $PSScriptRoot 'm365_icon.png' }, @{ ContentId = 'exchange_icon' - FilePath = 'C:\temp\exchange_icon.png' + FilePath = Join-Path $PSScriptRoot 'exchange_icon.png' }, @{ ContentId = 'sharepoint_icon' - FilePath = 'C:\temp\sharepoint_icon.png' + FilePath = Join-Path $PSScriptRoot 'sharepoint_icon.png' } ) -# Attachments +# Attachments (optional - comment out if not needed) $attachments = @( - "C:\temp\Anleitung_Erstanmeldung_Authentifizierung_mit_Smartphone.pdf", - "C:\temp\Anleitung_Erstanmeldung_Authentifizierung_mit_Telefon.pdf", - "C:\temp\Anleitung_Postfach_und_Dateiablage_auf_dem_Computer_einrichten.pdf", - "C:\temp\Anleitung_Einrichtung-Thunderbird.pdf" + # "C:\temp\Document1.pdf", + # "C:\temp\Document2.pdf" ) -# SMTP Configuration +# ============================================================================ +# SMTP CONFIGURATION - UPDATE THESE FOR YOUR ENVIRONMENT +# ============================================================================ + $smtpConfig = @{ Server = "smtp.office365.com" Port = 587 EnableSsl = $true - From = "OKR.noreply@ELK-WUE.DE" - Bcc = "jan.huebener@elkw.de" - Subject = "Microsoft 365 Monthly Digest – What's new?" + From = "noreply@yourdomain.com" # UPDATE: Your sender address + Bcc = "admin@yourdomain.com" # UPDATE: BCC for monitoring (optional) + Subject = "Microsoft 365 Monthly Digest - What's new?" } -# Authentication (Basic) -$smtpUsername = "adm_huebener@elkw.de" -$smtpPassword = "YourSecurePassword123!" # TODO: Replace with secure storage - # Batch Configuration $batchConfig = @{ BatchSize = 20 @@ -82,12 +117,107 @@ $batchConfig = @{ MaxRetries = 3 } -# Test Mode Configuration +# Test Mode - reduced settings for testing if ($ConfigMode -eq 'Test') { - Write-Host "`n⚠ RUNNING IN TEST MODE" -ForegroundColor Yellow + Write-Host "`n[TEST MODE] Using reduced batch settings" -ForegroundColor Yellow $batchConfig.BatchSize = 2 $batchConfig.WindowMinutes = 0.1 - $smtpConfig.Bcc = "jan.huebener@elkw.de" +} + +# ============================================================================ +# SECURE CREDENTIAL FUNCTIONS +# ============================================================================ + +function Get-SmtpCredentialSecure { + <# + .SYNOPSIS + Retrieves SMTP credentials using the specified method + #> + param( + [string]$Method, + [string]$Username, + [string]$SecureFilePath, + [string]$CredentialTarget + ) + + switch ($Method) { + 'Prompt' { + Write-Host " [Prompt] Enter SMTP credentials..." -ForegroundColor Cyan + $cred = Get-Credential -Message "Enter SMTP credentials for M365 Digest" + if (-not $cred) { + throw "Credential prompt was cancelled" + } + return $cred + } + + 'Environment' { + Write-Host " [Environment] Loading from environment variables..." -ForegroundColor Cyan + $envUser = $env:M365_SMTP_USERNAME + $envPass = $env:M365_SMTP_PASSWORD + + if ([string]::IsNullOrWhiteSpace($envUser)) { + throw "Environment variable M365_SMTP_USERNAME is not set" + } + if ([string]::IsNullOrWhiteSpace($envPass)) { + throw "Environment variable M365_SMTP_PASSWORD is not set" + } + + $securePass = ConvertTo-SecureString $envPass -AsPlainText -Force + return New-Object System.Management.Automation.PSCredential($envUser, $securePass) + } + + 'CredentialManager' { + Write-Host " [CredentialManager] Loading from Windows Credential Manager..." -ForegroundColor Cyan + + # Check if CredentialManager module is available + if (-not (Get-Module -ListAvailable -Name CredentialManager)) { + throw "CredentialManager module not installed. Install with: Install-Module CredentialManager" + } + + Import-Module CredentialManager -ErrorAction Stop + $cred = Get-StoredCredential -Target $CredentialTarget + + if (-not $cred) { + throw "Credential '$CredentialTarget' not found in Windows Credential Manager. Create it with: New-StoredCredential -Target '$CredentialTarget' -UserName 'user@domain.com' -Password 'password' -Persist LocalMachine" + } + return $cred + } + + 'SecureFile' { + Write-Host " [SecureFile] Loading from encrypted file..." -ForegroundColor Cyan + + if (-not (Test-Path $SecureFilePath)) { + Write-Host "`n Secure credential file not found. Creating one now..." -ForegroundColor Yellow + Write-Host " File: $SecureFilePath" -ForegroundColor Gray + + # Ensure directory exists + $secureDir = Split-Path $SecureFilePath -Parent + if (-not (Test-Path $secureDir)) { + New-Item -Path $secureDir -ItemType Directory -Force | Out-Null + } + + # Prompt for credentials and save encrypted + $newCred = Get-Credential -Message "Enter SMTP credentials to save (encrypted with your Windows account)" + if (-not $newCred) { + throw "Credential prompt was cancelled" + } + + $newCred | Export-Clixml -Path $SecureFilePath + Write-Host " [OK] Credentials saved to: $SecureFilePath" -ForegroundColor Green + Write-Host " Note: This file can only be decrypted by your Windows account on this machine.`n" -ForegroundColor Gray + + return $newCred + } + + # Load existing encrypted credential + $cred = Import-Clixml -Path $SecureFilePath + return $cred + } + + default { + throw "Unknown credential method: $Method" + } + } } # ============================================================================ @@ -95,72 +225,115 @@ if ($ConfigMode -eq 'Test') { # ============================================================================ try { - Write-Host "`n╔═══════════════════════════════════════════════════════════╗" -ForegroundColor Cyan - Write-Host "║ M365 Monthly Digest Email Campaign (Basic Auth) ║" -ForegroundColor Cyan - Write-Host "╚═══════════════════════════════════════════════════════════╝`n" -ForegroundColor Cyan + Write-Host "`n" -NoNewline + Write-Host "========================================================" -ForegroundColor Cyan + Write-Host " M365 Monthly Digest Email Campaign (Basic Auth) " -ForegroundColor Cyan + Write-Host "========================================================" -ForegroundColor Cyan + Write-Host " Mode: $ConfigMode" -ForegroundColor $(if ($ConfigMode -eq 'Test') { 'Yellow' } else { 'Green' }) + Write-Host " Credential Method: $CredentialMethod" -ForegroundColor Gray + Write-Host "========================================================`n" -ForegroundColor Cyan + + # ======================================================================== + # STEP 1: Validate file paths + # ======================================================================== + Write-Host "[1/5] Validating file paths..." -ForegroundColor Cyan - # Validate files exist - Write-Host "Validating configuration..." -ForegroundColor Cyan - if (-not (Test-Path $csvPath)) { throw "CSV file not found: $csvPath" } + Write-Host " [OK] CSV file: $csvPath" -ForegroundColor Green + if (-not (Test-Path $htmlTemplate)) { throw "HTML template not found: $htmlTemplate" } - + Write-Host " [OK] HTML template: $htmlTemplate" -ForegroundColor Green + + # ======================================================================== + # STEP 2: Validate inline images + # ======================================================================== + Write-Host "`n[2/5] Validating inline images..." -ForegroundColor Cyan + $missingImages = 0 foreach ($img in $inlineImages) { - if (-not (Test-Path $img.FilePath)) { - Write-Warning "Inline image not found: $($img.FilePath) (CID: $($img.ContentId))" + if (Test-Path $img.FilePath) { + $size = [math]::Round((Get-Item $img.FilePath).Length / 1KB, 1) + Write-Host " [OK] $($img.ContentId): ${size}KB" -ForegroundColor Green } - } - - foreach ($attachment in $attachments) { - if (-not (Test-Path $attachment)) { - Write-Warning "Attachment not found: $attachment" + else { + Write-Host " [WARN] Missing: $($img.FilePath)" -ForegroundColor Yellow + $missingImages++ } } + if ($missingImages -gt 0) { + Write-Host " [!] $missingImages image(s) missing - emails may display incorrectly" -ForegroundColor Yellow + } - Write-Host "✓ Configuration validated`n" -ForegroundColor Green + # ======================================================================== + # STEP 3: Get credentials securely + # ======================================================================== + Write-Host "`n[3/5] Obtaining SMTP credentials..." -ForegroundColor Cyan - # Get authentication credential - Write-Host "Authenticating..." -ForegroundColor Cyan - $credential = Get-EmailAuthenticationCredential ` + $credential = Get-SmtpCredentialSecure ` + -Method $CredentialMethod ` + -Username $SmtpUsername ` + -SecureFilePath $SecureFilePath ` + -CredentialTarget $CredentialTarget + + # Update SMTP config with credential + $smtpConfig.Credential = Get-EmailAuthenticationCredential ` -AuthMethod 'Basic' ` - -Username $smtpUsername ` - -Password $smtpPassword - - $smtpConfig.Credential = $credential - Write-Host "✓ Authentication configured`n" -ForegroundColor Green - - # Load and process CSV - Write-Host "Loading recipient data..." -ForegroundColor Cyan + -Username $credential.UserName ` + -Password $credential.GetNetworkCredential().Password + + # Update From address to match credential username if not explicitly set + if ($smtpConfig.From -match 'yourdomain.com') { + $smtpConfig.From = $credential.UserName + Write-Host " [INFO] Using credential username as From address" -ForegroundColor Gray + } + + Write-Host " [OK] Credentials loaded for: $($credential.UserName)" -ForegroundColor Green + + # ======================================================================== + # STEP 4: Load and validate recipients + # ======================================================================== + Write-Host "`n[4/5] Loading recipient data..." -ForegroundColor Cyan $csvData = Import-Csv -LiteralPath $csvPath -Delimiter ';' -Encoding UTF8 - # Build recipient objects + # Build recipient objects with validation $recipients = @() + $invalidEmails = @() + $emailRegex = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' + foreach ($row in $csvData) { $email = ($row.email).Trim() + + # Skip empty emails if ([string]::IsNullOrWhiteSpace($email)) { continue } + # Validate email format + if ($email -notmatch $emailRegex) { + $invalidEmails += $email + Write-Host " [WARN] Invalid email skipped: $email" -ForegroundColor Yellow + continue + } + # Build replacement hashtable for this recipient $replacements = @{ 'CARD1_TITLE' = "New Teams Features" 'CARD1_CONTENT' = "Microsoft Teams introduces new collaboration features including enhanced meeting recordings and AI-powered meeting summaries." 'CARD1_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1069560" - + 'CARD2_TITLE' = "Exchange Online Updates" 'CARD2_CONTENT' = "Enhanced security features now available for Exchange Online mailboxes, including improved phishing protection." 'CARD2_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1134178" - + 'CARD3_TITLE' = "SharePoint Improvements" 'CARD3_CONTENT' = "New document management capabilities in SharePoint Online with AI-powered search and classification." 'CARD3_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1069560" - - 'UNSUBSCRIBE_LINK' = "https://www.datagroup.de/unsubscribe?email=$email" + + 'UNSUBSCRIBE_LINK' = "https://www.yourdomain.com/unsubscribe?email=$email" } - # You can personalize per user if CSV has columns like DisplayName + # Personalize if DisplayName is available if ($row.PSObject.Properties.Name -contains 'DisplayName_email' -and $row.DisplayName_email) { $replacements['CARD1_CONTENT'] = "Hello $($row.DisplayName_email), " + $replacements['CARD1_CONTENT'] } @@ -171,7 +344,34 @@ try { } } - Write-Host "✓ Loaded $($recipients.Count) recipients`n" -ForegroundColor Green + Write-Host " [OK] Valid recipients: $($recipients.Count)" -ForegroundColor Green + if ($invalidEmails.Count -gt 0) { + Write-Host " [WARN] Invalid emails skipped: $($invalidEmails.Count)" -ForegroundColor Yellow + } + + if ($recipients.Count -eq 0) { + throw "No valid recipients found in CSV file" + } + + # ======================================================================== + # STEP 5: Display summary and confirm + # ======================================================================== + Write-Host "`n[5/5] Campaign Summary" -ForegroundColor Cyan + Write-Host "========================================================" -ForegroundColor Cyan + Write-Host " Recipients: $($recipients.Count)" + Write-Host " Batch Size: $($batchConfig.BatchSize)" + Write-Host " Window Interval: $($batchConfig.WindowMinutes) minutes" + Write-Host " Max Retries: $($batchConfig.MaxRetries)" + Write-Host " Sender: $($smtpConfig.From)" + Write-Host " Subject: $($smtpConfig.Subject)" + Write-Host " Checkpoint: $checkpointFile" + Write-Host "========================================================`n" -ForegroundColor Cyan + + if ($ConfigMode -eq 'Production' -and $recipients.Count -gt 10) { + Write-Host "[!] PRODUCTION MODE - Sending to $($recipients.Count) recipients" -ForegroundColor Yellow + Write-Host " Press Ctrl+C within 5 seconds to abort..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + } # Template Configuration $templateConfig = @{ @@ -194,11 +394,18 @@ try { Send-BulkHtmlEmail @bulkParams - Write-Host "`n✓ Campaign completed successfully!" -ForegroundColor Green + Write-Host "`n========================================================" -ForegroundColor Green + Write-Host " CAMPAIGN COMPLETED SUCCESSFULLY" -ForegroundColor Green + Write-Host "========================================================`n" -ForegroundColor Green } catch { - Write-Host "`n✗ ERROR: $($_.Exception.Message)" -ForegroundColor Red - Write-Host $_.ScriptStackTrace -ForegroundColor Red + Write-Host "`n========================================================" -ForegroundColor Red + Write-Host " CAMPAIGN FAILED" -ForegroundColor Red + Write-Host "========================================================" -ForegroundColor Red + Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "`n Stack Trace:" -ForegroundColor Gray + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "========================================================`n" -ForegroundColor Red exit 1 } diff --git a/Send-M365Digest-OAuth.ps1 b/Send-M365Digest-OAuth.ps1 new file mode 100644 index 0000000..1cea160 --- /dev/null +++ b/Send-M365Digest-OAuth.ps1 @@ -0,0 +1,373 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + M365 Digest Email Campaign - OAuth2 Authentication (Azure AD App) +.DESCRIPTION + Example script demonstrating bulk email sending with: + - OAuth2 Client Credentials Flow (Azure AD App Registration) + - CSV data import + - Template-based HTML emails + - Inline images (logo + 3 product icons) + - PDF attachments + - Batched sending with rate limiting +.NOTES + Requires: + - M365DigestEmailModule.psm1 + - Azure AD App Registration with Mail.Send permission + - Admin consent granted for the application + + Setup Steps: + 1. Register app in Azure AD: Azure Portal > App registrations > New + 2. Add API permission: Microsoft Graph > Application > Mail.Send + 3. Grant admin consent + 4. Create client secret and note the value + 5. Update configuration below with TenantId, ClientId, ClientSecret +.AUTHOR + Jan Huebener +.VERSION + 1.0.0 +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [ValidateSet('Test', 'Production')] + [string]$ConfigMode = 'Production' +) + +# ============================================================================ +# CONFIGURATION +# ============================================================================ + +# Import module +$modulePath = Join-Path $PSScriptRoot "M365DigestEmailModule.psm1" +Import-Module $modulePath -Force -Verbose + +# Paths - UPDATE THESE FOR YOUR ENVIRONMENT +$csvPath = "C:\Temp\master_users_all_merged_with_wave4.csv" +$htmlTemplate = Join-Path $PSScriptRoot "M365_Digest_Template.htm" +$checkpointFile = "C:\Temp\smtp_send_checkpoint_m365digest_oauth.txt" + +# Inline Images (CID must match template references) +$inlineImages = @( + @{ + ContentId = 'datagroup_logo' + FilePath = 'C:\temp\datagroup_logo.png' + }, + @{ + ContentId = 'm365_icon' + FilePath = Join-Path $PSScriptRoot 'm365_icon.png' + }, + @{ + ContentId = 'exchange_icon' + FilePath = Join-Path $PSScriptRoot 'exchange_icon.png' + }, + @{ + ContentId = 'sharepoint_icon' + FilePath = Join-Path $PSScriptRoot 'sharepoint_icon.png' + } +) + +# Attachments - UPDATE OR REMOVE AS NEEDED +$attachments = @( + # "C:\temp\Anleitung_Erstanmeldung_Authentifizierung_mit_Smartphone.pdf", + # "C:\temp\Anleitung_Erstanmeldung_Authentifizierung_mit_Telefon.pdf" +) + +# SMTP Configuration +$smtpConfig = @{ + Server = "smtp.office365.com" + Port = 587 + EnableSsl = $true + From = "noreply@yourdomain.com" # UPDATE: Sender address + Bcc = "admin@yourdomain.com" # UPDATE: BCC for monitoring + Subject = "Microsoft 365 Monthly Digest - What's new?" +} + +# ============================================================================ +# OAUTH CONFIGURATION - UPDATE THESE VALUES +# ============================================================================ +# +# To get these values: +# 1. Go to Azure Portal > Azure Active Directory > App registrations +# 2. Select your app (or create new one) +# 3. Overview page has: Application (client) ID and Directory (tenant) ID +# 4. Certificates & secrets > New client secret > Copy the VALUE (not ID) +# +# SECURITY WARNING: +# Do NOT commit real secrets to source control! +# Use secure storage methods in production: +# - Azure Key Vault +# - Windows Credential Manager +# - Environment variables +# - Encrypted config files + +$oauthConfig = @{ + TenantId = "your-tenant-id-here" # Directory (tenant) ID + ClientId = "your-client-id-here" # Application (client) ID + ClientSecret = "your-client-secret-here" # Client secret VALUE + Username = "noreply@yourdomain.com" # Must match From address +} + +# ============================================================================ +# SECURE CREDENTIAL LOADING (PRODUCTION RECOMMENDED) +# ============================================================================ +# Uncomment ONE of these sections for production use: + +# Option 1: Load from environment variables +# $oauthConfig = @{ +# TenantId = $env:M365_TENANT_ID +# ClientId = $env:M365_CLIENT_ID +# ClientSecret = $env:M365_CLIENT_SECRET +# Username = $env:M365_SENDER_EMAIL +# } + +# Option 2: Load from Azure Key Vault (requires Az.KeyVault module) +# Import-Module Az.KeyVault +# $vaultName = "your-keyvault-name" +# $oauthConfig = @{ +# TenantId = (Get-AzKeyVaultSecret -VaultName $vaultName -Name "M365-TenantId" -AsPlainText) +# ClientId = (Get-AzKeyVaultSecret -VaultName $vaultName -Name "M365-ClientId" -AsPlainText) +# ClientSecret = (Get-AzKeyVaultSecret -VaultName $vaultName -Name "M365-ClientSecret" -AsPlainText) +# Username = (Get-AzKeyVaultSecret -VaultName $vaultName -Name "M365-SenderEmail" -AsPlainText) +# } + +# Option 3: Load from encrypted file (Windows DPAPI - user-specific) +# $configPath = "C:\Secure\oauth_config.xml" +# if (Test-Path $configPath) { +# $encrypted = Import-Clixml $configPath +# $oauthConfig = @{ +# TenantId = $encrypted.TenantId +# ClientId = $encrypted.ClientId +# ClientSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto( +# [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($encrypted.ClientSecret) +# ) +# Username = $encrypted.Username +# } +# } + +# ============================================================================ +# Batch Configuration +# ============================================================================ + +$batchConfig = @{ + BatchSize = 20 + WindowMinutes = 3.0 + MaxRetries = 3 +} + +# Test Mode Configuration - reduced batch for testing +if ($ConfigMode -eq 'Test') { + Write-Host "`n[TEST MODE] Using reduced batch settings" -ForegroundColor Yellow + $batchConfig.BatchSize = 2 + $batchConfig.WindowMinutes = 0.1 +} + +# ============================================================================ +# VALIDATION +# ============================================================================ + +function Test-OAuthConfiguration { + param([hashtable]$Config) + + $valid = $true + + if ($Config.TenantId -match 'your-tenant-id|^$') { + Write-Host " [ERROR] TenantId not configured" -ForegroundColor Red + $valid = $false + } + if ($Config.ClientId -match 'your-client-id|^$') { + Write-Host " [ERROR] ClientId not configured" -ForegroundColor Red + $valid = $false + } + if ($Config.ClientSecret -match 'your-client-secret|^$') { + Write-Host " [ERROR] ClientSecret not configured" -ForegroundColor Red + $valid = $false + } + if ($Config.Username -match 'yourdomain.com|^$') { + Write-Host " [ERROR] Username (sender email) not configured" -ForegroundColor Red + $valid = $false + } + + return $valid +} + +# ============================================================================ +# MAIN EXECUTION +# ============================================================================ + +try { + Write-Host "`n" -NoNewline + Write-Host "================================================" -ForegroundColor Cyan + Write-Host " M365 Monthly Digest Email Campaign (OAuth2) " -ForegroundColor Cyan + Write-Host "================================================" -ForegroundColor Cyan + Write-Host " Mode: $ConfigMode" -ForegroundColor $(if ($ConfigMode -eq 'Test') { 'Yellow' } else { 'Green' }) + Write-Host "================================================`n" -ForegroundColor Cyan + + # Validate OAuth configuration + Write-Host "[1/6] Validating OAuth configuration..." -ForegroundColor Cyan + if (-not (Test-OAuthConfiguration -Config $oauthConfig)) { + throw "OAuth configuration is incomplete. Please update the script with your Azure AD app credentials." + } + Write-Host " [OK] OAuth configuration valid" -ForegroundColor Green + + # Validate file paths + Write-Host "`n[2/6] Validating file paths..." -ForegroundColor Cyan + + if (-not (Test-Path $csvPath)) { + throw "CSV file not found: $csvPath" + } + Write-Host " [OK] CSV file: $csvPath" -ForegroundColor Green + + if (-not (Test-Path $htmlTemplate)) { + throw "HTML template not found: $htmlTemplate" + } + Write-Host " [OK] HTML template: $htmlTemplate" -ForegroundColor Green + + # Validate inline images + Write-Host "`n[3/6] Validating inline images..." -ForegroundColor Cyan + $missingImages = 0 + foreach ($img in $inlineImages) { + if (Test-Path $img.FilePath) { + $size = [math]::Round((Get-Item $img.FilePath).Length / 1KB, 1) + Write-Host " [OK] $($img.ContentId): $($img.FilePath) (${size}KB)" -ForegroundColor Green + } + else { + Write-Host " [WARN] Missing: $($img.FilePath)" -ForegroundColor Yellow + $missingImages++ + } + } + if ($missingImages -gt 0) { + Write-Host " [!] $missingImages image(s) missing - emails may display incorrectly" -ForegroundColor Yellow + } + + # Validate attachments + Write-Host "`n[4/6] Validating attachments..." -ForegroundColor Cyan + if ($attachments.Count -eq 0) { + Write-Host " [INFO] No attachments configured" -ForegroundColor Gray + } + else { + foreach ($attachment in $attachments) { + if (Test-Path $attachment) { + $size = [math]::Round((Get-Item $attachment).Length / 1KB, 1) + Write-Host " [OK] $attachment (${size}KB)" -ForegroundColor Green + } + else { + Write-Host " [WARN] Missing: $attachment" -ForegroundColor Yellow + } + } + } + + # Acquire OAuth token + Write-Host "`n[5/6] Acquiring OAuth2 token..." -ForegroundColor Cyan + Write-Host " Tenant: $($oauthConfig.TenantId)" -ForegroundColor Gray + Write-Host " Client: $($oauthConfig.ClientId)" -ForegroundColor Gray + + $credential = Get-EmailAuthenticationCredential ` + -AuthMethod 'OAuth' ` + -Username $oauthConfig.Username ` + -TenantId $oauthConfig.TenantId ` + -ClientId $oauthConfig.ClientId ` + -ClientSecret $oauthConfig.ClientSecret + + $smtpConfig.Credential = $credential + $smtpConfig.From = $oauthConfig.Username + Write-Host " [OK] OAuth token acquired successfully" -ForegroundColor Green + + # Load recipients + Write-Host "`n[6/6] Loading recipient data..." -ForegroundColor Cyan + $csvData = Import-Csv -LiteralPath $csvPath -Delimiter ';' -Encoding UTF8 + + # Build recipient objects with template replacements + $recipients = @() + foreach ($row in $csvData) { + $email = ($row.email).Trim() + if ([string]::IsNullOrWhiteSpace($email)) { continue } + + # Build replacement hashtable for this recipient + # UPDATE THESE to match your actual content + $replacements = @{ + 'CARD1_TITLE' = "New Teams Features" + 'CARD1_CONTENT' = "Microsoft Teams introduces new collaboration features including enhanced meeting recordings and AI-powered meeting summaries." + 'CARD1_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1069560" + + 'CARD2_TITLE' = "Exchange Online Updates" + 'CARD2_CONTENT' = "Enhanced security features now available for Exchange Online mailboxes, including improved phishing protection." + 'CARD2_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1134178" + + 'CARD3_TITLE' = "SharePoint Improvements" + 'CARD3_CONTENT' = "New document management capabilities in SharePoint Online with AI-powered search and classification." + 'CARD3_LINK' = "https://admin.microsoft.com/?ref=MessageCenter/:/messages/MC1069560" + + 'UNSUBSCRIBE_LINK' = "https://www.yourdomain.com/unsubscribe?email=$email" + } + + # Personalize if DisplayName is available + if ($row.PSObject.Properties.Name -contains 'DisplayName_email' -and $row.DisplayName_email) { + $name = $row.DisplayName_email + $replacements['CARD1_CONTENT'] = "Hello $name, " + $replacements['CARD1_CONTENT'] + } + + $recipients += [PSCustomObject]@{ + Email = $email + Replacements = $replacements + } + } + + Write-Host " [OK] Loaded $($recipients.Count) recipients" -ForegroundColor Green + + # Display summary before sending + Write-Host "`n================================================" -ForegroundColor Cyan + Write-Host " CAMPAIGN SUMMARY" -ForegroundColor Cyan + Write-Host "================================================" -ForegroundColor Cyan + Write-Host " Recipients: $($recipients.Count)" + Write-Host " Batch Size: $($batchConfig.BatchSize)" + Write-Host " Window Interval: $($batchConfig.WindowMinutes) minutes" + Write-Host " Max Retries: $($batchConfig.MaxRetries)" + Write-Host " Sender: $($smtpConfig.From)" + Write-Host " Subject: $($smtpConfig.Subject)" + Write-Host " Auth: OAuth2 (Client Credentials)" + Write-Host "================================================`n" -ForegroundColor Cyan + + if ($ConfigMode -eq 'Production') { + Write-Host "[!] PRODUCTION MODE - Sending to ALL recipients" -ForegroundColor Yellow + Write-Host " Press Ctrl+C within 5 seconds to abort..." -ForegroundColor Yellow + Start-Sleep -Seconds 5 + } + + # Template Configuration + $templateConfig = @{ + TemplatePath = $htmlTemplate + Encoding = 'UTF8' + InlineImages = $inlineImages + Attachments = $attachments + } + + # Send bulk emails + $bulkParams = @{ + Recipients = $recipients + TemplateConfig = $templateConfig + SmtpConfig = $smtpConfig + BatchSize = $batchConfig.BatchSize + WindowMinutes = $batchConfig.WindowMinutes + MaxRetries = $batchConfig.MaxRetries + CheckpointPath = $checkpointFile + } + + Send-BulkHtmlEmail @bulkParams + + Write-Host "`n================================================" -ForegroundColor Green + Write-Host " CAMPAIGN COMPLETED SUCCESSFULLY" -ForegroundColor Green + Write-Host "================================================`n" -ForegroundColor Green + +} +catch { + Write-Host "`n================================================" -ForegroundColor Red + Write-Host " CAMPAIGN FAILED" -ForegroundColor Red + Write-Host "================================================" -ForegroundColor Red + Write-Host " Error: $($_.Exception.Message)" -ForegroundColor Red + Write-Host "`n Stack Trace:" -ForegroundColor Gray + Write-Host $_.ScriptStackTrace -ForegroundColor Gray + Write-Host "================================================`n" -ForegroundColor Red + exit 1 +} diff --git a/exchange_icon.png b/exchange_icon.png index da6c8e7..42f485f 100644 Binary files a/exchange_icon.png and b/exchange_icon.png differ diff --git a/m365_icon.png b/m365_icon.png index fe712f4..bbc8dbc 100644 Binary files a/m365_icon.png and b/m365_icon.png differ diff --git a/sharepoint_icon.png b/sharepoint_icon.png index e24e51b..09c8300 100644 Binary files a/sharepoint_icon.png and b/sharepoint_icon.png differ