Compare commits

66 Commits

Author SHA1 Message Date
Knee Cola
7a3a02bd6d chore: bump mailgun-webhook version to 1.0.1 and update lockfiles
- Bump mailgun-webhook-service version from 1.0.0 to 1.0.1
- Add mailgun-webhook to root workspace in package-lock.json
- Update all lockfiles to reflect workspace changes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:16:18 +01:00
Knee Cola
3d02654510 feat: add webhook signature verification and fix security issues
Security Improvements:
- Add HMAC-SHA256 signature verification for MailGun webhooks
- Remove hardcoded signing key fallback, require env variable
- Add proper payload structure validation before processing

API Changes:
- New types: MailgunWebhookPayload, MailgunWebhookPayloadSignature
- New type guard: isMailgunWebhookPayload()
- Returns 401 for invalid signatures, 400 for malformed payloads

Configuration:
- Add MAILGUN_WEBHOOK_SIGNING_KEY to both docker-compose files
- Service fails fast on startup if signing key not configured

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:13:09 +01:00
Knee Cola
0faac8e392 chore: update mailgun-webhook service URL to mailgun-webhook.rezije.app
- Change from webhook.rezije.app to mailgun-webhook.rezije.app for clarity
- Update both standalone and swarm configurations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 21:18:18 +01:00
Knee Cola
f9f33a2b45 feat: add mailgun-webhook service to docker-compose configurations
- Add mailgun-webhook service to both standalone and swarm deployments
- Configure service with Prometheus monitoring and debug logging
- Route traffic through Traefik at webhook.rezije.app
- Use version-controlled image with MAILGUN_WEBHOOK_VERSION variable

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 21:17:08 +01:00
Knee Cola
ccc690c369 chore: upgrade Node.js versions and improve Docker security
- Upgrade email-worker from Node 18 to Node 20
- Update distroless images to nodejs20-debian12:nonroot for both services
- Improves security by running containers as nonroot user

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 21:12:20 +01:00
d2725261d5 chore: add mailgun-webhook workspace to monorepo
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 16:08:50 +01:00
f169e2c4ba renamed script file 2026-01-07 16:08:02 +01:00
c72a06e34e feat: add CI/CD script for building Docker images across workspaces
Add ci-build-docker-image.sh script to automate Docker image builds in CI/CD pipelines. Script iterates through configured workspaces and builds images using --auto-version and --auto-push flags. Currently configured for mailgun-webhook, easily extensible for additional workspaces.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 13:16:28 +01:00
9d6507c3ae feat: add --auto-version flag with registry check to build-image.sh
Add --auto-version flag to automatically use version from package.json and check if image already exists in registry before building. If image exists, script exits to prevent duplicate versions. Rename --autopush to --auto-push for consistent flag naming.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 13:09:30 +01:00
6cf9b312c0 feat: add --autopush flag to build-image.sh for non-interactive builds
Add optional --autopush parameter to skip the interactive push prompt and automatically push built images to registry. Useful for CI/CD pipelines and automated builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 12:51:41 +01:00
45d5507bf9 refactor: delegate security to CloudFlare and clean up mailgun-webhook
Remove application-level CORS and IP whitelisting as security is now handled at CloudFlare edge. CORS is not applicable for backend webhook service, and IP whitelisting is more effectively managed at infrastructure layer. Also translate Dockerfile comments to English and add registry URL to build script.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 12:47:07 +01:00
Knee Cola
d081386b9f feat: add Delivered status to notification enums
- Add Delivered status to BillsNotificationStatus enum
- Add Delivered status to RentNotificationStatus enum
- Update JSDoc comments for clarity
- Delivered status will be set by webhook when email is confirmed delivered

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 13:24:20 +01:00
Knee Cola
5dc78cfcaf feat: add Bounced status to Bills and Rent notification enums 2026-01-06 13:14:15 +01:00
Knee Cola
17fd9ea54c fix: remove problematic mailgun.js/Types import
- Remove unused MailgunMessageData type import
- Remove type annotations for messageData
- Resolves TypeScript compilation error in email-worker

The mailgun.js package doesn't properly expose Types module,
and the type annotation wasn't necessary since mailgunClient
is already typed as any.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 13:09:38 +01:00
Knee Cola
fb35e0278e refactor: improve notification naming and introduce type-safe enums
- Rename billFwd* to billsNotification* for clarity
- Rename rentDueNotification* to rentNotification* for consistency
- Rename utilBillsProofOfPayment to billsProofOfPayment
- Introduce enums for type safety:
  - BillsNotificationStrategy (WhenPayed, WhenAttached)
  - BillsNotificationStatus (Scheduled, Sent, Failed)
  - RentNotificationStatus (Sent, Failed)
- Replace "pending" status with "scheduled" for better semantics
- Fix function names to proper camelCase
- Fix incorrect import path in web-app/app/lib/format.ts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-06 13:05:22 +01:00
Knee Cola
0556ad2533 chore: simplify email-worker Docker setup
Remove private registry references and SSH key handling from Docker
build process. Images now build locally without registry prefix.

Changes:
- Remove SSH key copying from Dockerfile (no private npm packages)
- Remove registry.ngit.hr references from build and run scripts
- Simplify image tags to local format (no registry prefix)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-02 21:01:10 +01:00
Knee Cola
7aeea9353d feat: implement MailGun webhook service for logging email events
Implemented a production-ready TypeScript/Express.js service to receive
and log MailGun webhook events (delivered, failed, opened, clicked, etc.).

Key features:
- Webhook endpoint (POST /webhook) with comprehensive event logging
- Full TypeScript type definitions for all MailGun event types
- Prometheus metrics integration for monitoring
- Health check endpoint (GET /ping)
- Comprehensive Jest test suite with 87.76% coverage
- Docker containerization with build scripts

Removed template/example code:
- All SQL/MSSQL dependencies and related code
- Example auth router and middleware
- PRTG metrics support (simplified to Prometheus only)
- Unused middleware (CORS, IP whitelist, request parsing/validation)
- Template documentation (kept only MailGun webhook API spec)

The service is clean, minimal, and focused solely on receiving and
logging MailGun webhook events to the console.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-02 20:56:22 +01:00
Knee Cola
4371a9a20a added test variable when sending an e-mail 2026-01-02 19:17:54 +01:00
Knee Cola
997f4acf57 feat: add locale prefixes to all email template URLs
Updated all email templates to include locale prefixes (/en/ or /hr/)
in URLs to ensure language consistency when users click email links.

Changes:
- Email validation templates: /email/verify/ → /{locale}/email/verify/
- Rent due templates: /share/rent-due/ → /{locale}/share/rent-due/
- Bills due templates: /share/bills-due/ → /{locale}/share/bills-due/
- Unsubscribe links: /email/unsubscribe/ → /{locale}/email/unsubscribe/
- Base site links in EN templates: / → /en

Benefits:
- Users land on pages in the same language as their email
- Better UX - no manual language switching needed
- Proper i18n implementation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 12:09:35 +01:00
Knee Cola
494d358130 feat: add rent-due share page for rent payment information
Created a new /share/rent-due/ page to display rent payment information
separately from utility bills.

Changes:
- Created /share/rent-due/[id]/ page structure with RentViewPage component
- Created ViewRentCard component to display rent amount and payment info
- Added uploadRentProofOfPayment action for tenant proof upload
- Added translation keys for rent-specific labels (en/hr)
- Updated rent email templates to link to /share/rent-due/ instead of /share/bills-due/
- Updated documentation to reflect new URL structure

The rent page displays:
- Rent amount
- IBAN or Revolut payment information with QR/barcode
- Rent proof of payment upload (when enabled)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 11:56:01 +01:00
Knee Cola
1e83172491 refactor: rename share URL path from location to bills-due
- Renamed directory: app/[locale]/share/location -> app/[locale]/share/bills-due
- Updated all URL references in web-app (billActions, locationActions, ViewBillCard)
- Updated all URL references in email-worker templates (4 email templates)
- Updated documentation in sprints/email-worker.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 11:27:50 +01:00
Knee Cola
d7abd99448 feat: implement language-based email template selection
Update email worker to select templates based on BillingLocation.tenantEmailLanguage:
- Change default language from 'en' to 'hr' in emailTemplates.ts
- Pass language parameter (location.tenantEmailLanguage || 'hr') to all loadAndRender calls
- Applies to email validation, rent due, and utility bills notifications

Email templates are now automatically selected based on tenant's language preference,
defaulting to Croatian when not specified.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 11:07:39 +01:00
Knee Cola
7bf7f9580f feat: add Croatian versions of email templates
Add Croatian (hr) translations for all email templates:
- email-validation: email verification request
- rent-due: rent payment due notification
- util-bills-due: utility bills notification

All templates maintain same structure and styling as English versions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 11:07:39 +01:00
Knee Cola
5feab991ec refactor: reorder language selector before email input
Moved the language selector to appear before the email address field for better
UX flow. Users now select the notification language first, then enter the email
address that will receive notifications in that language.

Also applied right-alignment to the "new email" status indicator for consistency.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:53:04 +01:00
Knee Cola
08a9215128 style: right-align email status indicators
Added justify-end to email status display to align icons and text to the right
side of the container for better visual hierarchy.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:51:03 +01:00
Knee Cola
532ad4c0e0 refactor: move language selector into tenant email fieldset
Improved UI organization by moving the language selector into the same fieldset
as the tenant email input, since both are related to email notifications. The
language selector now only appears when email notifications are enabled.

Changes:
- Move language selector inside tenant email fieldset (no separate fieldset)
- Add label for tenant email input field
- Simplify notification language label text
- Remove unused notification-language-legend translation key
- Language selector shares visibility condition with email field

This provides a more logical grouping of related settings and reduces visual
clutter when email notifications are disabled.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:48:38 +01:00
Knee Cola
69f891210e feat: add language selector for tenant notification emails
Added ability to select the language for automatic notification emails sent to
tenants. Users can choose between Croatian (hr) and English (en). If not set,
defaults to the current UI language.

Changes:
- Add tenantEmailLanguage field to BillingLocation type (shared-code)
- Add language selector fieldset in LocationEditForm below email settings
- Add Zod validation for tenantEmailLanguage in locationActions
- Include field in all database insert and update operations
- Default to current locale if not explicitly set
- Add translation labels for language selector (EN/HR)

This allows tenants to receive bills and notifications in their preferred language
regardless of the landlord's UI language preference.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:37:14 +01:00
Knee Cola
2bc5cad82d refactor: simplify forwarding check in multi-bill-edit
Removed unnecessary loop when checking if bill forwarding should be triggered.
Since we only need to determine if forwarding should happen once per location
(after all bills are updated), we can check with just the first paid bill
instead of looping through all updates.

Benefits:
- More efficient (no redundant checks for unpaid bills)
- Clearer logic (one check per location, not per bill update)
- Avoids confusion about checking bills that weren't marked as paid

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:24:27 +01:00
Knee Cola
7e4ea26a7c refactor: extract bill forwarding helpers and add to multi-bill-edit
Created shared billForwardingHelpers module to avoid code duplication and
implemented automatic bill forwarding trigger in the multi-bill-edit feature.

Changes:
- Extract shouldUpdateBillFwdStatusWhenAttached and shouldUpdateBillFwdStatusWhenPayed
  to new billForwardingHelpers.ts module
- Update billActions.ts to import from shared module instead of local definitions
- Add forwarding logic to monthActions.updateMonth to set billFwdStatus to "pending"
  when all tenant bills are marked as paid

This ensures consistent bill forwarding behavior whether updating bills individually
or in bulk via the multi-bill-edit page.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:10:47 +01:00
Knee Cola
b44d5afca6 fix: store rent amount as whole currency units instead of cents
Changed rent amount handling to use whole currency units throughout the application
instead of storing in cents. This simplifies data entry and aligns with Zod validation
requiring integer values.

Changes:
- Set rent input step to "1" (whole numbers only)
- Remove cents-to-currency conversion (/ 100) when formatting for email

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 10:03:12 +01:00
Knee Cola
2c4e0ee5c0 improve: clarify automatic notification toggle labels
Made toggle labels more explicit by adding "to tenant automatically" to clearly
indicate the recipient and automatic nature of the notifications.

Changes:
- "forward utility bills" → "forward utility bills to tenant automatically"
- "send rent notification" → "send rent notification to tenant automatically"
- Fixed capitalization in Croatian: "obavjest O" → "obavjest o"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 09:56:39 +01:00
Knee Cola
554dd8617f feat: require updateScope selection and improve form UX
Location Edit Form:
- Add validation requiring user to select update scope when editing locations
- Add "no-scope-selected" as placeholder option that must be replaced with valid choice
- Display validation error if user attempts to submit without selecting scope
- Clarify update scope options with improved wording (e.g., "ALL months (past and future)")

Bill Form UX:
- Add emoji icons (👤 tenant, 🔑 landlord) to "who bears cost" options for visual clarity

Translation updates:
- Add "update-scope-required" validation message (EN/HR)
- Improve clarity of update scope option labels
- Standardize Croatian terminology ("zadani" instead of "trenutni" for current month)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 09:47:26 +01:00
Knee Cola
7e7eb5a2d8 fix: only trigger bill forwarding for tenant-paid bills
Modified bill forwarding logic to only consider bills marked as "billed to tenant"
when determining if all bills are ready for forwarding. Bills billed to landlord
should not affect the forwarding trigger.

Changes:
- Filter out landlord bills before checking if all bills are paid/attached
- Improved status check to explicitly look for "pending" or "sent" status
- Added edge case handling when no tenant bills exist

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-31 09:44:55 +01:00
Knee Cola
580951b9c6 refactor: improve type safety in MongoDB operations
Add TypeScript generic type parameters to MongoDB collection calls and
remove ObjectId conversion workaround.

Changes:
- Added <BillingLocation> type parameter to collection().updateOne() calls
- Simplified _id usage by removing new ObjectId() conversion
- Cleaner code with better type inference

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 20:01:29 +01:00
Knee Cola
bb66ebe3b1 fix: use zero-width spaces to prevent email auto-linking
Replace &#46; encoding with zero-width space (&#8203;) approach to prevent
email clients from auto-linking "rezije.app" in header and body text.

Changed "rezije.app" to "rezije&#8203;.&#8203;app" which displays normally
but breaks the auto-link detection pattern.

Applied to:
- Header h1 tags in all templates
- Body text mentions in email-validation template

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 19:17:15 +01:00
Knee Cola
a7d13ba6dc fix: encode period in email template header to prevent auto-linking
Replace the period in "rezije.app" header text with HTML entity &#46; to
prevent email clients from automatically converting it into a clickable link.

This change only affects the display text in the h1 header, not the actual
URLs which remain unchanged for proper functionality.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 19:09:22 +01:00
Knee Cola
c7e81a27ee fix: update remaining imports in web-app actions to use shared-code
Fix imports in app/lib/actions/ that were still using relative paths
('../db-types') instead of the shared-code package.

Updated files:
- billActions.ts
- emailActions.ts
- locationActions.ts
- monthActions.ts
- navigationActions.ts
- printActions.ts
- userSettingsActions.ts

All imports now correctly reference @evidencija-rezija/shared-code.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:42:43 +01:00
Knee Cola
767dda6355 feat: refactor email-worker to use HTML email templates
Replace inline HTML in email notifications with professional HTML templates
for better email client compatibility and consistent branding.

Changes:
- Created emailTemplates.ts utility for loading and rendering templates
  - Template caching for performance
  - Variable substitution with ${variable} syntax
  - Warning logging for unreplaced variables

- Updated sendVerificationRequests to use email-validation template
  - Variables: location.tenantName, ownerName, location.name, shareId

- Updated sendRentDueNotifications to use rent-due template
  - Fetches user settings for owner name and currency
  - Calculates rent due date from yearMonth and rentDueDay
  - Formats rent amount (converts cents to display format)
  - Variables: location.tenantName, location.name, rentDueDate,
    rentAmount, currency, ownerName, shareId

- Updated sendUtilityBillsNotifications to use util-bills-due template
  - Calculates total amount from all bills
  - Fetches user settings for owner name and currency
  - Variables: location.tenantName, location.name, totalAmount,
    currency, ownerName, shareId

- Fixed ObjectId type mismatches in MongoDB operations

All emails now feature:
- Responsive 3-column layout
- rezije.app branding with logo
- Professional typography and spacing
- Unsubscribe links
- Email client compatible table-based layouts

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:37:34 +01:00
Knee Cola
3c34627e7e chore: add shared-code to VS Code workspace
Add the shared-code workspace to VS Code workspace configuration for
better development experience and navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:30:08 +01:00
Knee Cola
4bac7f4677 refactor: migrate web-app to use shared-code package
Update web-app to use @evidencija-rezija/shared-code for common types
and utilities instead of maintaining duplicate copies.

Changes:
- Add shared-code dependency to package.json
- Update all imports across 35+ files to use @evidencija-rezija/shared-code
- Remove duplicate db-types.ts and shareChecksum.ts files

This ensures type consistency between web-app and email-worker and
reduces maintenance burden.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:30:00 +01:00
Knee Cola
625e468951 refactor: migrate email-worker to use shared-code package
Update email-worker to use @evidencija-rezija/shared-code for common types
and utilities instead of maintaining duplicate copies.

Changes:
- Add shared-code dependency to package.json
- Update imports in emailSenders.ts to use shared-code
- Remove duplicate db-types.ts and shareChecksum.ts files

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:29:34 +01:00
Knee Cola
50c8d230f7 feat: create shared-code workspace for common code
Create a new shared-code workspace containing common code shared between
web-app and email-worker. This reduces code duplication and ensures
consistency across workspaces.

Structure:
- Root package.json defines workspaces (web-app, email-worker, shared-code)
- shared-code contains db-types.ts and shareChecksum.ts
- Configured as internal npm package (@evidencija-rezija/shared-code)
- No build step required (TypeScript source consumed directly)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 18:27:53 +01:00
Knee Cola
5a7fb35bd8 (refactor) moved email templates to emaikl-worker workspace 2025-12-30 18:03:28 +01:00
Knee Cola
35e20c8195 refactor: rename email templates with language suffix
Rename email templates to follow a consistent naming pattern with language
suffix for future i18n support:
- email-validation-email.html -> email-template--email-validation--en.html
- rent-due-email.html -> email-template--rent-due--en.html
- utility-bills-due-email.html -> email-template--util-bills-due--en.html

This naming convention allows for easy addition of Croatian (hr) and other
language versions of the same templates.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:58:35 +01:00
Knee Cola
3db0348b8d (config) email-worker: .env populated with working credentials 2025-12-30 17:51:41 +01:00
Knee Cola
5f99ba26c4 feat: update recipient email address in test message 2025-12-30 17:45:33 +01:00
Knee Cola
742521ef4a feat: add professional HTML email notification templates
Add three email templates for tenant notifications with consistent branding
and professional design:

1. email-validation-email.html - Email verification request
2. rent-due-email.html - Rent payment due reminder
3. utility-bills-due-email.html - Utility bills ready notification

All templates feature:
- Responsive 3-column layout with center content area
- rezije.app branding with logo and color scheme (#0070F3 blue, #3c3c3d header)
- Email client compatible table-based layouts with inline CSS
- Template variables for dynamic content (tenant name, location, amounts, etc.)
- Unsubscribe links and footer information
- Professional typography and spacing

Templates use ${variable} syntax for server-side replacement.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:32 +01:00
Knee Cola
1ed82898c6 feat: improve verification email subject and content
Update email verification subject line to be more personal and inviting by
including the landlord's name. Also add the property location name to the
email body to provide better context for the recipient.

- Subject changed from "Please verify your e-mail address" to
  "{ownerName} has invited you to rezije.app"
- Added location name to email body for clarity

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:23 +01:00
Knee Cola
7b4e1b2710 feat: configure Mailgun EU API endpoint
Add explicit EU API endpoint configuration for Mailgun client to ensure emails
are sent through the correct regional API server. This is required for accounts
created in the EU region.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:16 +01:00
Knee Cola
1c98b3b2e6 feat: add nodemon configuration for email-worker development
Configure nodemon to automatically load environment variables from .env file
using dotenv/config preload. This ensures all environment variables are available
during development without manual loading.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:11 +01:00
Knee Cola
63b575e07a feat: add reset button for failed email verification status
Add a reset button to the email verification failed status display, allowing users
to retry email verification after a failure. The button appears alongside the error
message and icon for easy access.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:06 +01:00
Knee Cola
3e769d30f9 fix: add missing translations for verification-failed email status
Add English and Croatian translation keys for the verification-failed email status
that were missing from the previous UI implementation commit.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 17:44:01 +01:00
Knee Cola
2e08289e47 feat: add UI support for VerificationFailed email status
Display verification failed status in location cards and edit forms to provide
clear visual feedback when email verification fails. Uses red X icon and error
styling consistent with other failure states.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 13:08:06 +01:00
Knee Cola
aa6ae91db8 feat: add billFwdStatus auto-trigger for when-payed strategy
Enables automatic email notification trigger when all bills are marked as paid
under "when-payed" forwarding strategy. Complements the existing "when-attached"
strategy to support both notification workflows.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 13:03:01 +01:00
Knee Cola
e26a478577 feat: auto-set billFwdStatus to pending when all bills have attachments
Enables automatic email notification trigger when the last bill receives an
attachment under "when-attached" forwarding strategy. This eliminates manual
intervention for marking locations ready for tenant notification.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:58:16 +01:00
Knee Cola
2b6999d728 fix: update environment configuration for email worker 2025-12-30 12:46:11 +01:00
Knee Cola
6a1a5e0dca fix: update Mailgun API key and share link secret in environment configuration 2025-12-30 12:45:40 +01:00
Knee Cola
a901980a6f feat: implement email notification worker with Mailgun integration
- Add MongoDB connection module for database access
- Implement Mailgun email service for sending notifications
- Add shareChecksum utility for generating secure share links
- Implement three email sender functions:
  - Email verification requests (highest priority)
  - Rent due notifications (CET timezone)
  - Utility bills due notifications
- Create main email worker with budget-based email sending
- Add environment variables for configuration
- Install dependencies: mongodb, mailgun.js, form-data
- Update package.json description to reflect email worker purpose
- Add .env.example with all required configuration

The worker processes emails in priority order and respects a configurable
budget to prevent overwhelming the mail server. All database operations are
atomic and updates are performed immediately after each email send.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:27:32 +01:00
Knee Cola
33ab06e22e commit changes 2025-12-30 12:20:06 +01:00
Knee Cola
4a6896c910 docs: improve email-worker specification and add VerificationFailed status
- Fix spelling and grammar errors throughout email-worker.md
- Add DB structure and connection sections
- Add error handling for email send failures
- Add email subjects, sender information, and delivery details
- Add logging requirements section
- Add race condition handling guidelines
- Add email provider specification (Mailgun)
- Add EmailStatus.VerificationFailed enum value for failed verification emails

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 12:16:30 +01:00
Knee Cola
f8291f9f7b docs: add initial email-worker implementation specification
Add comprehensive specification document for email-worker service detailing:
  - Email verification request workflow
  - Rent due notification workflow
  - Utility bills due notification workflow
  - Email budget/throttling strategy (default: 10 emails per run)
  - Priority system (verification requests first)
  - Complete email templates with dynamic content
  - MongoDB integration requirements
2025-12-30 11:29:05 +01:00
Knee Cola
e9012ed231 (refactor) renames autoBillFwd > billFwdEnabled 2025-12-30 11:09:21 +01:00
Knee Cola
4906cc1990 (refactor) rename: rentDueNotification > rentDueNotificationEnabled 2025-12-30 10:45:48 +01:00
Knee Cola
3e4d8fb95c refactor: rename email-server-worker to email-worker
Rename directory from email-server-worker to email-worker for clarity and brevity. Update all references in CLAUDE.md documentation.
2025-12-30 10:33:59 +01:00
Knee Cola
9d6ad17452 chore: add development configuration files
Add .env with default development settings and .vscode debug configurations for easier local development.

- .env: Development environment variables (PORT, DEBUG, PULL_INTERVAL)
- .vscode/launch.json: Debug configurations for server and Jest tests
- .vscode/settings.json: Jest integration settings

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 09:05:31 +01:00
Knee Cola
25c2f09eef feat: add email-server-worker with clean template architecture
Add new email-server-worker project implementing a self-scheduling background worker pattern with HTTP monitoring. Removed all business-specific code from copied source, creating a clean, reusable template.

Key features:
- Self-scheduling worker loop with configurable interval
- Graceful shutdown support (Docker-compatible)
- Prometheus metrics collection
- Health check endpoints (/healthcheck, /metrics, /ping)
- Example worker template for easy customization
- Comprehensive architecture documentation in CLAUDE.md

The worker is now ready for email server implementation with no external dependencies on Evolution/MSSQL/ElasticSearch.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-30 09:03:58 +01:00
144 changed files with 42891 additions and 201 deletions

View File

@@ -24,7 +24,12 @@
"Bash(git mv:*)",
"Bash(rmdir:*)",
"Bash(mkdir:*)",
"Bash(git diff:*)"
"Bash(git diff:*)",
"Bash(grep:*)",
"Bash(for file in CommError HTTPResponseError JsonParsingError clientIpAddress)",
"Bash(do echo \"=== $file ===\")",
"Bash(done)",
"Bash(git restore:*)"
]
},
"enableAllProjectMcpServers": true,

2
.gitignore vendored
View File

@@ -52,3 +52,5 @@ mongo-backup/
.serena/
*.serena-memory
mongo-backup/
.git/

133
CLAUDE.md
View File

@@ -8,6 +8,7 @@ This is a multi-project repository containing:
- **web-app/**: Next.js 14 utility bills tracking application
- **docker-stack/**: Docker Compose configurations and deployment scripts
- **housekeeping/**: Database backup and maintenance scripts
- **email-worker/**: Background worker service with HTTP health monitoring
Each project is self-contained with its own dependencies.
@@ -29,6 +30,13 @@ All commands should be run from within the respective project directory.
- `./db-dump--standalone.sh` - Run standalone database dump
- See housekeeping/README.md for more details
**Email Server Worker** (`cd email-worker`):
- `npm install` - Install dependencies
- `npm run start` - Start development server with nodemon
- `npm run build` - Build TypeScript to JavaScript
- `npm run test` - Run tests with Jest in watch mode
- `npm run run-server` - Run built server from ./build directory
## Deployment Commands
**Building Docker Image** (`cd web-app`):
@@ -91,4 +99,127 @@ export const actionName = withUser(async (user: AuthenticatedUser, ...args) => {
### Testing & Code Quality
- ESLint with Next.js and Prettier configurations
- No specific test framework configured - check with user before assuming testing approach
- No specific test framework configured - check with user before assuming testing approach
## Email Server Worker Architecture
The email-worker is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection.
### Tech Stack
- **Runtime**: Node.js with TypeScript
- **Framework**: Express for HTTP endpoints
- **Metrics**: Prometheus (prom-client) with custom PRTG adapter
- **Testing**: Jest with TypeScript support
### Core Architecture: Worker Pattern
The service implements a **self-contained worker pattern** that runs periodic background tasks while exposing HTTP endpoints for monitoring.
**Entry Point** (`email-worker/src/entry.ts:1`):
- Creates Express HTTP server with graceful shutdown support (stoppable)
- Starts the worker via `startSyncWorker()` from `email-worker/src/workRunner.ts:134`
- Handles SIGTERM/SIGINT for graceful shutdown (Docker-compatible)
- Calls `disposeSyncWorker()` on shutdown to allow pending work to complete
**Work Runner** (`email-worker/src/workRunner.ts:1`):
The work runner implements a self-scheduling loop with the following characteristics:
- **Self-Scheduling Loop**: After completing work, schedules next execution via `setTimeout(workRunner, PULL_INTERVAL)` at `email-worker/src/workRunner.ts:113`
- **Graceful Shutdown**: Tracks pending work via Promise, allows in-flight operations to complete before shutdown
- **Status Tracking**: Exports `workerRunnerInfo` with `status` and `lastWorkTime` for health monitoring
- **Error Isolation**: Worker errors don't crash the process - caught, logged, and execution continues
- **Metrics Integration**: Automatic Prometheus metrics collection (duration, success/failure counters)
- **Single Work Instance**: Ensures only one work cycle runs at a time via `pendingWork` Promise
Work Runner States (WorkerRunnerStatus enum):
- `init` - Initial state before first run
- `beginWork` - Work cycle started
- `workDone` - Work completed successfully
- `disposed` - Worker stopped, no longer scheduling
- Other states track Prometheus stats updates
**Worker Implementation Pattern**:
Workers must export a `doWork` function with signature:
```typescript
export const doWork = async () => {
// Perform periodic work here
// Throw errors to increment failedRequestCounter
// Return normally to increment successfulRequestCounter
};
```
The work runner imports and calls this function at `email-worker/src/workRunner.ts:88`.
### Key Files & Responsibilities
**Core Worker Files**:
- `email-worker/src/entry.ts` - HTTP server setup, signal handling, worker lifecycle management
- `email-worker/src/workRunner.ts` - Self-scheduling loop, graceful shutdown, metrics integration
- `email-worker/src/app.ts` - Express app configuration, route registration
- `email-worker/src/lib/logger.ts` - Debug logger factory (uses 'debug' package)
**HTTP Routes** (`email-worker/src/routes/`):
- `healthcheckRouter.ts` - Health check endpoint (checks worker status via `workerRunnerInfo`)
- `metricsRouter.ts` - Prometheus metrics endpoint
- `prtgMetricsRouter.ts` - PRTG-compatible metrics adapter
- `pingRouter.ts` - Simple ping/pong endpoint
- `errorRouter.ts` - Structured error handler for expected errors
- `finalErrorRouter.ts` - Catch-all error handler for unexpected errors
**Infrastructure**:
- `email-worker/src/lib/metricsCounters.ts` - Prometheus counter/histogram definitions
- `email-worker/src/lib/initTools.ts` - Utility functions (coalesce, etc.)
- `email-worker/src/lib/serializeError.ts` - Error serialization for logging
- `email-worker/src/lib/Prometheus2Prtg.ts` - Converts Prometheus metrics to PRTG XML format
### Environment Variables
**Required**:
- `PULL_INTERVAL` - Milliseconds between work cycles (default: "10000")
**Optional**:
- `PORT` - HTTP server port (default: "3000")
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: "evo-open-table-sync-svc")
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: "0.1, 0.5, 1, 5, 10")
- `DEBUG` - Debug namespaces for console logging (e.g., "server:server")
- `ENV` - Environment mode: "dev", "jest" (affects logging behavior)
### Creating a New Worker
To implement a new worker task:
1. **Create worker file** (e.g., `email-worker/src/myWorker.ts`):
```typescript
export const doWork = async () => {
// Implement your periodic task here
logger.info("Work Title", "Work completed successfully");
// Throw errors to mark as failed:
// throw new Error("Something went wrong");
};
```
2. **Update `workRunner.ts`** import at line 6:
```typescript
import { doWork } from "./myWorker";
```
3. **Add environment variables** to `email-worker/src/types/environment.d.ts` as needed
4. **Update `package.json` metadata** if the service purpose changes (name, description)
### Docker Deployment
- Uses `stoppable` library for graceful shutdown (10-second timeout before force-close)
- Health check endpoint at `/healthcheck` verifies worker is running and not stalled
- Prometheus metrics at `/metrics` for monitoring
- PRTG-compatible metrics at `/prtg` for legacy monitoring systems
- Graceful shutdown ensures work in progress completes before container stops
### Testing
- **Framework**: Jest with esbuild-jest for TypeScript
- **Test Location**: `email-worker/tests/`
- **Mocks**: Common mocks in `email-worker/tests/__mocks__/` (prom-client)
- **Test Pattern**: Co-located with source in `tests/` mirroring `src/` structure
- **Run Tests**: `npm run test` (watch mode)

36
ci-build.sh Executable file
View File

@@ -0,0 +1,36 @@
#!/bin/bash
# CI/CD script to build and push Docker images for workspace projects
# Uses version from package.json and automatically pushes to registry
set -e # Exit on error
# List of workspaces to build
WORKSPACES=(
"mailgun-webhook"
)
printf "\n=== CI/CD Docker Image Build ===\n"
printf "Building %d workspace(s)\n\n" "${#WORKSPACES[@]}"
for WORKSPACE_DIR in "${WORKSPACES[@]}"; do
printf "\n--- Building workspace: %s ---\n\n" "$WORKSPACE_DIR"
if [ ! -d "$WORKSPACE_DIR" ]; then
printf "\nERROR: Directory '%s' does not exist. Skipping.\n\n" "$WORKSPACE_DIR"
continue
fi
if [ ! -f "$WORKSPACE_DIR/build-image.sh" ]; then
printf "\nERROR: build-image.sh not found in '%s'. Skipping.\n\n" "$WORKSPACE_DIR"
continue
fi
cd "$WORKSPACE_DIR"
./build-image.sh --auto-version --auto-push
cd ..
printf "\n--- Completed: %s ---\n" "$WORKSPACE_DIR"
done
printf "\n=== All builds completed successfully! ===\n\n"

View File

@@ -79,3 +79,22 @@ services:
- traefik.http.routers.mongo-express.entrypoints=http
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
mailgun-webhook:
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
networks:
- traefik-network
environment:
PORT: 3000
PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
container_name: evidencija-rezija__mailgun-webhook
restart: unless-stopped
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
- traefik.http.routers.mailgun-webhook.entrypoints=http
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)

View File

@@ -79,3 +79,25 @@ services:
- traefik.http.routers.mongo-express.entrypoints=http
- traefik.http.routers.mongo-express.rule=Host(`mongo.rezije.app`)
mailgun-webhook:
image: registry.budakova.org/mailgun-webhook-service:${MAILGUN_WEBHOOK_VERSION:-latest}
networks:
- traefik-network
environment:
PORT: 3000
PROMETHEUS_APP_LABEL: mailgun-webhook-service
PROMETHEUS_HISTOGRAM_BUCKETS: 0.1,0.5,1,5,10
DEBUG: server:*,app:*
MAILGUN_WEBHOOK_SIGNING_KEY: ${MAILGUN_WEBHOOK_SIGNING_KEY}
deploy:
restart_policy:
condition: any
delay: 5s
max_attempts: 0
labels:
- traefik.enable=true
- traefik.docker.network=traefik-network
- traefik.http.services.mailgun-webhook.loadbalancer.server.port=3000
- traefik.http.routers.mailgun-webhook.entrypoints=http
- traefik.http.routers.mailgun-webhook.rule=Host(`mailgun-webhook.rezije.app`)

View File

@@ -1,27 +0,0 @@
# Email Server Worker
This workspace contains the email server worker service for the Evidencija Režija tenant notification system.
## Purpose
This service manages email operations by:
- Polling MongoDB for email status changes
- Detecting unverified tenant emails (EmailStatus.Unverified)
- Sending verification emails to tenants
- Updating email status to VerificationPending
- Sending scheduled notifications (rent due, utility bills)
## Architecture
This is a standalone background worker service that:
- Runs independently from the Next.js web-app
- Communicates via the shared MongoDB database
- Integrates with email service provider (e.g., Mailgun, SendGrid)
## Setup
TBD
## Environment Variables
TBD

View File

@@ -0,0 +1,7 @@
tests
.git
coverage
node_modules
jest.config.ts
service-tester.sh
build-image.sh

27
email-worker/.env Normal file
View File

@@ -0,0 +1,27 @@
# Worker Configuration
PULL_INTERVAL=60000
EMAIL_BUDGET=10
# MongoDB Configuration
MONGODB_URI=mongodb://root:HjktJCPWMBtM1ACrDaw7@localhost:27017
# Mailgun Configuration
# MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
MAILGUN_API_KEY=f581edcac21ec14d086ef25e36f04432-e61ae8dd-e207f22b
MAILGUN_DOMAIN=rezije.app
# Security
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
# Server Configuration
PORT=3001
# Logging
DEBUG=*
# Prometheus Metrics (optional)
PROMETHEUS_APP_LABEL=email-worker
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
# Environment
ENV=dev

26
email-worker/.env.example Normal file
View File

@@ -0,0 +1,26 @@
# Worker Configuration
PULL_INTERVAL=60000
EMAIL_BUDGET=10
# MongoDB Configuration
MONGODB_URI=mongodb://localhost:27017/utility-bills
# Mailgun Configuration
MAILGUN_API_KEY=d660e320e0cdeecc125d4ecd6bc5cd66-e61ae8dd-01bdd61a
MAILGUN_DOMAIN=rezije.app
# Security
SHARE_LINK_SECRET=fb831e43b5ab594106e093f86fa8cb2a2405c564a61c3a7681079ec416528654
# Server Configuration
PORT=3001
# Logging
DEBUG=worker:*,email:*,db:*
# Prometheus Metrics (optional)
PROMETHEUS_APP_LABEL=email-worker
PROMETHEUS_HISTOGRAM_BUCKETS=0.1, 0.5, 1, 5, 10
# Environment
ENV=dev

2
email-worker/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
node_modules
build

58
email-worker/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,58 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"start"
],
"sourceMaps": true,
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
{
"type": "node",
"name": "vscode-jest-tests-1634200842588",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
]
}

10
email-worker/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"jest.jestCommandLine": "npm run test --",
"jest.autoRun": {
"watch": false,
"onSave": "test-file"
},
"jest.nodeEnv": {
}
}

65
email-worker/Dockerfile Normal file
View File

@@ -0,0 +1,65 @@
#--------------------------------------------
# Stage: building TypeScript
#--------------------------------------------
FROM node:20 as build-stage
ENV WORKDIR=/app
WORKDIR /app
COPY ./package*.json ./
# instaliram pakete
RUN npm i && npm cache clean --force
COPY ./tsconfig.json ./
COPY ./src ./src
RUN npm run build
#--------------------------------------------
# Stage: instaliram produkcijski node_modules
#--------------------------------------------
FROM node:18 as package-stage
WORKDIR /app
COPY ./package*.json ./
# instaliram SAMO produkcijske
RUN npm i --only=production && npm cache clean --force
#--------------------------------------------
# Stage: priprema finalnog image-a
#--------------------------------------------
FROM gcr.io/distroless/nodejs20-debian12:nonroot as assembly-stage
WORKDIR /app
ARG PORT="3000"
ENV PORT=${PORT}
# prometheus config
ARG PROMETHEUS_APP_LABEL
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}
ARG PROMETHEUS_HISTOGRAM_BUCKETS
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}
# (optional) logiranje na stdout (moguće opcije: "server:server", "server:metrics", "server:healthcheck" )
ARG DEBUG
ENV DEBUG=${DEBUG}
# kopiram node-modules
COPY --from=package-stage /app/package*.json ./
COPY --from=package-stage /app/node_modules ./node_modules
# kopiram buildane datoteke
COPY --from=build-stage /app/build/ ./server
# server vrtim pod ograničenim "nobody" korisnikom
USER nobody:nobody
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
# pokrećem server
CMD ["./server/entry.js"]

115
email-worker/README.md Normal file
View File

@@ -0,0 +1,115 @@
# Email Server Worker
Background worker service with HTTP health monitoring and metrics collection.
## Overview
This is a TypeScript-based background worker service that combines periodic task execution with HTTP health monitoring and metrics collection. It implements a self-scheduling worker pattern with graceful shutdown support.
## Features
- **Periodic Task Execution**: Self-scheduling worker loop with configurable interval
- **Graceful Shutdown**: Ensures in-flight work completes before shutdown (Docker-compatible)
- **Health Monitoring**: HTTP health check endpoint to verify worker status
- **Metrics Collection**: Prometheus metrics with PRTG adapter
- **Error Isolation**: Worker errors don't crash the process
## Getting Started
### Installation
```bash
npm install
```
### Development
```bash
npm run start # Start with nodemon (auto-reload)
```
### Build & Run
```bash
npm run build # Compile TypeScript
npm run run-server # Run compiled version
```
### Testing
```bash
npm run test # Run Jest in watch mode
```
## Environment Variables
### Required
- `PULL_INTERVAL` - Milliseconds between work cycles (default: `"10000"`)
### Optional
- `PORT` - HTTP server port (default: `"3000"`)
- `PROMETHEUS_APP_LABEL` - App label for Prometheus metrics (default: `"email-worker"`)
- `PROMETHEUS_HISTOGRAM_BUCKETS` - Histogram bucket sizes (default: `"0.1, 0.5, 1, 5, 10"`)
- `DEBUG` - Debug namespaces for console logging (e.g., `"server:server"`)
- `ENV` - Environment mode: `"dev"`, `"jest"` (affects logging)
## HTTP Endpoints
- `GET /healthcheck` - Health check endpoint (verifies worker is running)
- `GET /metrics` - Prometheus metrics
- `GET /prtg` - PRTG-compatible metrics (XML format)
- `GET /ping` - Simple ping/pong endpoint
## Creating a Worker
See `src/exampleWorker.ts` for the worker template. The worker must export a `doWork` function:
```typescript
export const doWork = async () => {
// Your periodic task logic here
logger.info("Task Completed", "Work done successfully");
// Throw errors to mark as failed:
// throw new Error("Something went wrong");
};
```
Update `src/workRunner.ts` line 6 to import your worker:
```typescript
import { doWork } from "./yourWorker";
```
## Architecture
- **entry.ts** - HTTP server setup, signal handling, worker lifecycle
- **workRunner.ts** - Self-scheduling loop, metrics, graceful shutdown
- **app.ts** - Express app configuration, routes
- **src/lib/** - Shared utilities (logger, metrics, etc.)
- **src/routes/** - HTTP route handlers
## Deployment
The service uses the `stoppable` library for graceful shutdown with a 10-second timeout before force-closing connections. Docker containers will receive SIGTERM signals and shut down gracefully.
## Logging
The service supports two logging mechanisms:
1. **Console Logging**: Uses the `debug` package, controlled by `DEBUG` env variable
## Metrics
Prometheus metrics are automatically collected:
- `request_operations_total` - Total work cycles executed
- `request_operations_ok` - Successful work cycles
- `request_operations_failed` - Failed work cycles
- `request_duration_seconds` - Duration histogram of work cycles
## Documentation
See `CLAUDE.md` in the repository root for complete architecture documentation and guidance.

29
email-worker/build-image.sh Executable file
View File

@@ -0,0 +1,29 @@
#!/bin/bash
if [ "$1" == "" ] ; then
printf "\nDocker image version not set - please specify the version to build"
printf "\n\nSyntax:\n\n build-image.sh 1.0.0\n\n"
exit 1
fi
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
echo # (optional) move to a new line
PUSH_IMAGE_TO_REPO="$REPLY"
printf "\nBUILD START ...\n\n"
IMAGE_NAME=evo-open-table-sync-svc
IMAGE_VERSION=$1
IMAGE_TAG=$IMAGE_NAME:$IMAGE_VERSION
docker build . -t $IMAGE_TAG
# if [[ "$PUSH_IMAGE_TO_REPO" =~ ^[Yy]$ ]]
# then
# printf "\nPushing image ...\n\n"
# docker push $IMAGE_TAG
# fi
printf "\nBUILD DONE!\n\n"

View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Verify Your Email - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
You have received this email because your landlord <strong>${ownerName}</strong> wants to us to send you rent and utility bills invoices for <strong>${location.name}</strong> each month.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>What is this all about?</strong><br>
<strong>rezije&#8203;.&#8203;app</strong> is an online app which helps property owners manage expenses related to properties they lease.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
By sending rent and utility bills invoices via email, your landlord can ensure that you receive accurate and timely notifications about your payments, making it easier for both of you to keep track of your financial obligations.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>How many e-mails will I receive?</strong><br>
E-mails are sent two times a month at most: once the rent is due, and once when all the utility bills ready.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>We need your confirmation</strong><br>
Before we start sending you any bills via email, we would ask you for your permission.
If you accept, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/email/verify/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
I accept
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/email/verify/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
<strong>Don't want to receive these emails?</strong><br>
You can ignore this email if you don't want to receive notifications. You can also unsubscribe at any time using the link included in every notification email.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,143 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Potvrdite svoju e-mail adresu - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Primili ste ovaj email jer Vaš vlasnik nekretnine <strong>${ownerName}</strong> želi da Vam mjesečno šaljemo obavijesti o najamnini i režijama za <strong>${location.name}</strong>.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>O čemu se radi?</strong><br>
<strong>rezije&#8203;.&#8203;app</strong> je online aplikacija koja pomaže vlasnicima nekretnina da upravljaju troškovima vezanim uz nekretnine koje iznajmljuju.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Slanjem obavijesti o najamnini i režijama putem e-maila, Vaš vlasnik može osigurati da primite točne i pravovremene obavijesti o plaćanjima, što olakšava praćenje financijskih obveza za oboje.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>Koliko e-mailova ću primati?</strong><br>
E-mailovi se šalju maksimalno dva puta mjesečno: jednom kada dospijeva najamnina, i jednom kada su sve režije spremne.
</p>
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
<strong>Potrebna je Vaša potvrda</strong><br>
Prije nego što počnemo slati Vam račune putem e-maila, molimo Vas za dozvolu.
Ako prihvaćate, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/email/verify/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6 ; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Prihvaćam
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/email/verify/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
<strong>Ne želite primati ove e-mailove?</strong><br>
Možete zanemariti ovaj email ako ne želite primati obavijesti. Također se možete odjaviti u bilo kojem trenutku koristeći link koji se nalazi u svakoj obavijesti.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rent Payment Due - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
This is a friendly reminder that your rent payment for <strong>${location.name}</strong> is due on <strong>${rentDueDate}</strong>.
</p>
<!-- Rent Amount Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Amount Due
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${rentAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
To view payment details and attach your proof of payment, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/share/rent-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
View Payment Details
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/share/rent-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
If you have any questions or concerns about this payment, please contact your landlord <strong>${ownerName}</strong> directly.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Unsubscribe from these emails
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dospjela najamnina - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Ovo je podsjetnik da Vaša najamnina za <strong>${location.name}</strong> dospijeva <strong>${rentDueDate}</strong>.
</p>
<!-- Rent Amount Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Iznos za uplatu
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${rentAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Za detalje o uplatama i prilaganje potvrde o plaćanju, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/share/rent-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Pogledaj detalje uplate
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/share/rent-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
Ako imate bilo kakvih pitanja ili nedoumica u vezi ovog plaćanja, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Odjavi se od ovih e-mailova
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Utility Bills Available - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Property Management Made Easy
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Hello <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
All utility bills for <strong>${location.name}</strong> are now due.
</p>
<!-- Notification Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Total Amount Due
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${totalAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
To view bills and payment information, please click the button below:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/en/share/bills-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
View Payment Details
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Or copy and paste this link into your browser:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/en/share/bills-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
If you have any questions or concerns, please contact your landlord <strong>${ownerName}</strong> directly.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Thank you!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Unsubscribe from these emails
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/en" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Visit rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. All rights reserved.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,145 @@
<!DOCTYPE html>
<html lang="hr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dostupne režije - rezije.app</title>
</head>
<body style="margin: 0; padding: 0; font-family: Arial, Helvetica, sans-serif; background-color: #f4f4f4;">
<!-- Wrapper Table -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="background-color: #f4f4f4;">
<tr>
<td style="padding: 20px 0;">
<!-- Main Container Table (3 columns) -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="max-width: 600px; margin: 0 auto;">
<tr>
<!-- Left Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
<!-- Center Content Column -->
<td width="80%" style="background-color: #ffffff; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<!-- Header with Logo -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 20px; background-color: #3c3c3d; border-radius: 8px 8px 0 0;">
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="width: 80px; vertical-align: middle; padding-right: 15px;">
<img src="https://rezije.app/icon4.png" alt="rezije.app logo" style="display: block; border: none; max-width: 100%;">
</td>
<td style="vertical-align: middle; text-align: left;">
<h1 style="margin: 0; font-size: 28px; color: #ffffff; font-weight: bold;">
rezije&#8203;.&#8203;app
</h1>
<p style="margin: 5px 0 0 0; font-size: 14px; color: #BAE6FD;">
Jednostavno upravljanje nekretninama
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
<!-- Content -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 30px 30px 20px 30px;">
<p style="margin: 0 0 20px 0; font-size: 16px; line-height: 24px; color: #333333;">
Poštovani <strong>${location.tenantName}</strong>,
</p>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Sve režije za <strong>${location.name}</strong> sada su dostupne.
</p>
<!-- Notification Box -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%" style="margin-bottom: 30px;">
<tr>
<td style="background-color: #F0F9FF; border-left: 4px solid #0070F3; padding: 20px; border-radius: 4px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666; text-transform: uppercase; letter-spacing: 0.5px;">
Ukupan iznos za uplatu
</p>
<p style="margin: 0; font-size: 32px; font-weight: bold; color: #0070F3;">
${totalAmount} ${currency}
</p>
</td>
</tr>
</table>
<p style="margin: 0 0 30px 0; font-size: 16px; line-height: 24px; color: #333333;">
Za pregled računa i informacije o uplatama, molimo kliknite gumb ispod:
</p>
<!-- CTA Button -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="text-align: center; padding: 0 0 30px 0;">
<a href="https://rezije.app/hr/share/bills-due/${shareId}"
style="display: inline-block; padding: 14px 40px; background-color: #5b5ba6; color: #ffffff; text-decoration: none; font-size: 16px; font-weight: bold; border-radius: 6px; border: none;">
Pogledaj detalje uplate
</a>
</td>
</tr>
</table>
<p style="margin: 0 0 10px 0; font-size: 14px; line-height: 22px; color: #666666; text-align: center;">
Ili kopirajte i zalijepite ovaj link u svoj preglednik:
</p>
<p style="margin: 0 0 20px 0; font-size: 13px; line-height: 20px; color: #0070F3; text-align: center; word-break: break-all;">
https://rezije.app/hr/share/bills-due/${shareId}
</p>
<!-- Divider -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 0;">
<div style="border-top: 1px solid #E5E7EB;"></div>
</td>
</tr>
</table>
<p style="margin: 0 0 20px 0; font-size: 14px; line-height: 22px; color: #666666;">
Ako imate bilo kakvih pitanja ili nedoumica, molimo kontaktirajte svog vlasnika <strong>${ownerName}</strong> izravno.
</p>
<p style="margin: 0; font-size: 16px; line-height: 24px; color: #333333;">
Hvala!
</p>
</td>
</tr>
</table>
<!-- Footer -->
<table role="presentation" cellspacing="0" cellpadding="0" border="0" width="100%">
<tr>
<td style="padding: 20px 30px; text-align: center; background-color: #F9FAFB; border-radius: 0 0 8px 8px;">
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app/hr/email/unsubscribe/${shareId}" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Odjavi se od ovih e-mailova
</a>
</p>
<p style="margin: 0 0 10px 0; font-size: 14px; color: #666666;">
<a href="https://rezije.app" target="_blank" style="color: #0070F3; text-decoration: none; font-weight: bold;">
Posjetite rezije.app
</a>
</p>
<p style="margin: 0; font-size: 12px; color: #999999;">
© 2025 rezije.app. Sva prava pridržana.
</p>
</td>
</tr>
</table>
</td>
<!-- Right Spacer Column -->
<td width="10%" style="background-color: #f4f4f4;"></td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@@ -0,0 +1,39 @@
/** @type {import('jest/dist/types').InitialOptionsTsJest} */
import type { Config } from 'jest/build/index';
const config:Config = {
// preset: 'ts-jest',
transform: {
'^.+\\.tsx?$': [
'esbuild-jest', {
sourcemap:true, // bez ovog VS code umjesto originala prikazuje transpilirane datoteke
target:'es2020' // ovo je nužno kako bi BigInt funkcionirao
}]
},
maxWorkers: 4,
testEnvironment: 'node',
// The root directory that Jest should scan for tests and modules within
rootDir: "./",
// A list of paths to directories that Jest should use to search for files in
roots: [
"<rootDir>/tests",
],
// The glob patterns Jest uses to detect test files
testMatch: [
"**/?(*.)+(spec).[tj]s?(x)",
],
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/"],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ["/node_modules/", "/build/"],
// Indicates whether each individual test should be reported during the run
verbose: true,
setupFiles: [
'dotenv/config', // učitaj varijable iz .env i učini ih dostupne testiranom software-u
]
};
module.exports = config;

View File

@@ -0,0 +1,7 @@
{
"watch": ["src"],
"ext": "ts",
"execMap": {
"ts": "node -r dotenv/config -r ts-node/register"
}
}

9788
email-worker/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

54
email-worker/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "email-worker",
"version": "0.1.0",
"description": "Email notification worker service for sending verification requests, rent due notices, and utility bills notifications",
"main": "entry.ts",
"scripts": {
"start": "nodemon ./src/entry.ts",
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js",
"build": "ttsc --project ./",
"test": "ENV=jest jest --watch"
},
"author": "Nikola",
"license": "ISC",
"dependencies": {
"@evidencija-rezija/shared-code": "^1.0.0",
"debug": "^2.6.9",
"express": "^4.18.2",
"form-data": "^4.0.5",
"http-errors": "^1.7.2",
"mailgun.js": "^12.4.1",
"mongodb": "^7.0.0",
"node-fetch": "^2.6.7",
"prom-client": "^14.0.1",
"stoppable": "^1.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.18.6",
"@types/debug": "^4.1.7",
"@types/express": "^4.17.13",
"@types/http-errors": "^1.8.1",
"@types/jest": "^29.2.5",
"@types/mongodb": "^4.0.6",
"@types/node": "^16.10.2",
"@types/node-fetch": "^2.6.2",
"@types/stoppable": "^1.1.1",
"@types/supertest": "^2.0.11",
"dotenv": "^16.0.3",
"esbuild": "^0.16.14",
"esbuild-jest": "^0.5.0",
"jest": "^29.3.1",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"ttypescript": "^1.5.15",
"typescript": "^4.9.4",
"typescript-transform-paths": "^3.4.4"
},
"babel": {
"presets": [
"@babel/preset-typescript"
]
}
}

13
email-worker/run-image.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
if [ "$1" == "" ] ; then
printf "\nNisi zadao verziju Docker image-a koji treba pokrenuti"
printf "\n\nSintaksa:\n\n run-image.sh 1.0.0\n\n"
exit 1
fi
IMAGE_TAG=evo-open-table-sync-svc:$1
docker run -p 3000:3000 \
--env DEBUG=* \
$IMAGE_TAG

View File

@@ -13,7 +13,7 @@ async function sendSimpleMessage() {
console.log("Sending email...");
const data = await mg.messages.create("rezije.app", {
from: "Mailgun Sandbox <support@rezije.app>",
to: ["Nikola Derezic <nikola.derezic@gmail.com>"],
to: ["Nikola Derezic <armful-grief-knoll@duck.com>"],
subject: "Hello Nikola Derezic",
text: "Congratulations Nikola Derezic, you just sent an email with Mailgun! You are truly awesome!",
});

34
email-worker/src/app.ts Normal file
View File

@@ -0,0 +1,34 @@
import express from 'express';
import createError from 'http-errors';
import { errorRouter } from './routes/errorRouter';
import { finalErrorRouter } from './routes/finalErrorRouter';
import { metricsRouter } from './routes/metricsRouter';
import { pingRouter } from './routes/pingRouter';
import { healthcheckRouter } from './routes/healthcheckRouter';
import { SupportedRoutes } from './types/enums/SupportedRoutes';
const app = express();
// u slučaju kada se server vrti iza proxy-a
// ovaj flag će natjerati Express da informacije poput
// IP adrese klijenta, protokola uzima iz X-Forward-*
// HTTP header polja, koja postavlja proxy
app.set('trust proxy', true);
// prometheus sa ove rute dohvaća zadnje važeću statistiku
app.use(SupportedRoutes.metricsPath, metricsRouter);
app.use(SupportedRoutes.ping, pingRouter);
app.use(SupportedRoutes.healthcheck, healthcheckRouter);
// default handler
app.use((req, res, next) => next(createError(404)));
// error handler za sve predviđene greške
app.use(errorRouter);
// error router za nepredviđene greške
app.use(finalErrorRouter);
export default app;

View File

@@ -0,0 +1,70 @@
import { connectToDatabase, disconnectFromDatabase } from './lib/dbClient';
import { sendVerificationRequests, sendRentDueNotifications, sendUtilityBillsNotifications } from './lib/emailSenders';
import { createLogger } from './lib/logger';
const log = createLogger("worker:email");
/**
* Email worker implementation
*
* Sends three types of emails in priority order:
* 1. Email verification requests (highest priority)
* 2. Rent due notifications
* 3. Utility bills due notifications
*
* Uses a budget system to limit total emails sent per run.
*/
export const doWork = async () => {
const startTime = Date.now();
const emailBudget = parseInt(process.env.EMAIL_BUDGET || '10', 10);
log(`Starting email worker run with budget: ${emailBudget}`);
let remainingBudget = emailBudget;
let totalSent = 0;
try {
// Connect to database
const db = await connectToDatabase();
// 1. Send verification requests (highest priority)
const verificationsSent = await sendVerificationRequests(db, remainingBudget);
totalSent += verificationsSent;
remainingBudget -= verificationsSent;
log(`Verification emails sent: ${verificationsSent}, remaining budget: ${remainingBudget}`);
// 2. Send rent due notifications
if (remainingBudget > 0) {
const rentSent = await sendRentDueNotifications(db, remainingBudget);
totalSent += rentSent;
remainingBudget -= rentSent;
log(`Rent due emails sent: ${rentSent}, remaining budget: ${remainingBudget}`);
}
// 3. Send utility bills notifications
if (remainingBudget > 0) {
const billsSent = await sendUtilityBillsNotifications(db, remainingBudget);
totalSent += billsSent;
remainingBudget -= billsSent;
log(`Utility bills emails sent: ${billsSent}, remaining budget: ${remainingBudget}`);
}
// Disconnect from database
await disconnectFromDatabase();
const workDuration = Date.now() - startTime;
log(`Email worker completed in ${workDuration}ms. Total emails sent: ${totalSent}`);
} catch (error) {
log(`Email worker failed: ${error}`);
// Try to disconnect even on error
try {
await disconnectFromDatabase();
} catch (disconnectError) {
log(`Failed to disconnect from database: ${disconnectError}`);
}
throw error; // Re-throw to mark work as failed
}
};

122
email-worker/src/entry.ts Executable file
View File

@@ -0,0 +1,122 @@
import app from './app';
import http from 'http';
import stoppable from 'stoppable';
import { createLogger } from './lib/logger';
import { disposeSyncWorker, startSyncWorker } from './workRunner';
const logger = createLogger("server:server");
/**
* Normalize a port into a number, string, or false.
*/
const normalizePort = (val:string):string|number|boolean => {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
};
/**
* Event listener for HTTP server "error" event.
*/
const onError = (error:any):void => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
const onListening = ():void => {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr?.port;
logger(`⚡️[server]: Server is running at ${bind}`);
};
/**
* Get port from environment and store in Express.
*/
const port:number|string|boolean = normalizePort(process.env.PORT || '3000');
/**
* How long should stoppable wait before it starts force-closing connections
* @description wait max 10 seconds - needs to be shorter than `healthcheck.timeout` (=15sec)
*/
const FORCE_STOP_TIMEOUT = 10000;
/**
* Create HTTP server.
*/
const server = stoppable( http.createServer(app), FORCE_STOP_TIMEOUT );
// Listen on provided port, on all network interfaces.
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
/**
* Starting sync worker process
*/
startSyncWorker();
// quit on ctrl-c when running docker in terminal
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGINT', () => {
logger('Got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// quit properly on docker stop
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGTERM', () => {
logger('Got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// shut down server
const shutdown = async () => {
await disposeSyncWorker();
// NOTE: server.close is for express based apps
// If using hapi, use `server.stop`
server.close((err) => {
if (err) {
console.error(err);
process.exitCode = 1;
} else {
logger('Exiting server process...');
}
process.exit();
});
};

View File

@@ -0,0 +1,33 @@
/**
* Example worker implementation
*
* This is a placeholder worker that demonstrates the worker pattern.
* Replace this with your actual worker implementation.
*
* The worker is called periodically by workRunner.ts based on PULL_INTERVAL.
*
* @throws Error to increment failedRequestCounter in Prometheus
* @returns Promise that resolves when work is complete (increments successfulRequestCounter)
*/
export const doWork = async () => {
const startTime = Date.now();
// TODO: Implement your periodic worker logic here
// Examples:
// - Fetch data from external API
// - Process queued tasks from database
// - Send scheduled emails
// - Clean up expired records
// - Sync data between systems
const workDuration = Date.now() - startTime;
// Log success (only in non-test environments)
if (process.env.ENV !== "jest") {
const logMessage = `Example worker completed in ${workDuration}ms`;
console.log(logMessage);
}
// Note: Throw errors to mark work as failed:
// throw new Error("Something went wrong");
};

View File

@@ -0,0 +1,29 @@
import { createLogger } from "./lib/logger";
import http, { IncomingMessage } from "http";
const logger = createLogger("server:healthcheck");
const options = {
host: "localhost",
port: "3000",
timeout: 2000,
path: '/healthcheck/'
};
const request = http.request(options, (res:IncomingMessage) => {
logger(`Healthcheck: STATUS ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on("error", function (err:any) {
logger("Healthcheck: ERROR");
process.exit(1);
});
request.end();

View File

@@ -0,0 +1,53 @@
import { MongoClient, Db } from 'mongodb';
import { createLogger } from './logger';
const log = createLogger("db:client");
let client: MongoClient | null = null;
let db: Db | null = null;
/**
* Connect to MongoDB
* @returns Database instance
*/
export async function connectToDatabase(): Promise<Db> {
if (!process.env.MONGODB_URI) {
throw new Error('MONGODB_URI environment variable is not set');
}
if (db) {
log('Reusing existing database connection');
return db;
}
log('Creating new database connection');
client = new MongoClient(process.env.MONGODB_URI);
await client.connect();
db = client.db("utility-bills");
log('Connected to database');
return db;
}
/**
* Disconnect from MongoDB
*/
export async function disconnectFromDatabase(): Promise<void> {
if (client) {
log('Disconnecting from database');
await client.close();
client = null;
db = null;
log('Disconnected from database');
}
}
/**
* Get current database instance (must call connectToDatabase first)
*/
export function getDatabase(): Db {
if (!db) {
throw new Error('Database not connected. Call connectToDatabase() first.');
}
return db;
}

View File

@@ -0,0 +1,284 @@
import { Db, ObjectId } from 'mongodb';
import { BillingLocation, BillsNotificationStatus, EmailStatus, RentNotificationStatus, UserSettings, generateShareId } from '@evidencija-rezija/shared-code';
import { sendEmail } from './mailgunService';
import { createLogger } from './logger';
import { loadAndRender } from './emailTemplates';
const log = createLogger("email:senders");
/**
* Send email verification requests
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendVerificationRequests(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping verification requests');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1; // JavaScript months are 0-indexed
log(`Fetching locations for verification: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Unverified
})
.toArray();
log(`Found ${locations.length} locations needing verification`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during verification sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
const html = loadAndRender('email-validation', {
'location.tenantName': location.tenantName || 'there',
'ownerName': ownerName,
'location.name': location.name,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `${ownerName} has invited you to rezije.app`,
html
});
// Update location status
const newStatus = success ? EmailStatus.VerificationPending : EmailStatus.VerificationFailed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { tenantEmailStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Verification email sent to ${location.tenantEmail}`);
} else {
log(`Failed to send verification email to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} verification emails`);
return sentCount;
}
/**
* Send rent due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendRentDueNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping rent due notifications');
return 0;
}
const now = new Date();
// Use CET timezone
const cetDate = new Date(now.toLocaleString('en-US', { timeZone: 'Europe/Belgrade' }));
const currentYear = cetDate.getFullYear();
const currentMonth = cetDate.getMonth() + 1;
const currentDay = cetDate.getDate();
log(`Fetching locations for rent due: year=${currentYear}, month=${currentMonth}, day=${currentDay}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'rentNotificationEnabled': true,
'rentDueDay': currentDay,
$or: [
{ 'rentNotificationStatus': { $exists: false } },
{ 'rentNotificationStatus': null }
]
})
.toArray();
log(`Found ${locations.length} locations needing rent due notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during rent due sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
// Format rent due date
const rentDueDate = `${location.yearMonth.month}/${location.rentDueDay}/${location.yearMonth.year}`;
// Format rent amount (convert from cents to display format)
const rentAmount = location.rentAmount ? (location.rentAmount).toFixed(2) : '0.00';
const currency = userSettings?.currency || 'EUR';
const html = loadAndRender('rent-due', {
'location.tenantName': location.tenantName || 'there',
'location.name': location.name,
'rentDueDate': rentDueDate,
'rentAmount': rentAmount,
'currency': currency,
'ownerName': ownerName,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `Rent due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? RentNotificationStatus.Sent : RentNotificationStatus.Failed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { rentNotificationStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Rent due notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send rent due notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} rent due notifications`);
return sentCount;
}
/**
* Send utility bills due notifications
* @param db Database instance
* @param budget Remaining email budget
* @returns Number of emails sent
*/
export async function sendUtilityBillsNotifications(db: Db, budget: number): Promise<number> {
if (budget <= 0) {
log('Budget exhausted, skipping utility bills notifications');
return 0;
}
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth() + 1;
log(`Fetching locations for utility bills: year=${currentYear}, month=${currentMonth}`);
const locations = await db.collection<BillingLocation>('lokacije')
.find({
'yearMonth.year': currentYear,
'yearMonth.month': currentMonth,
'tenantEmailStatus': EmailStatus.Verified,
'billsNotificationEnabled': true,
'billsNotificationStatus': BillsNotificationStatus.Scheduled
})
.toArray();
log(`Found ${locations.length} locations needing utility bills notifications`);
let sentCount = 0;
for (const location of locations) {
if (budget <= 0) {
log('Budget exhausted during utility bills sending');
break;
}
if (!location.tenantEmail) {
log(`Skipping location ${location._id}: no tenant email`);
continue;
}
// Fetch user settings
const userSettings = await db.collection<UserSettings>('userSettings')
.findOne({ userId: location.userId });
const ownerName = userSettings?.ownerName || '';
const shareId = generateShareId(location._id.toString());
// Calculate total amount from all bills
const totalAmountCents = (location.bills || []).reduce((sum, bill) => {
return sum + (bill.payedAmount || 0);
}, 0);
const totalAmount = (totalAmountCents / 100).toFixed(2);
const currency = userSettings?.currency || 'EUR';
const html = loadAndRender('util-bills-due', {
'location.tenantName': location.tenantName || 'there',
'location.name': location.name,
'totalAmount': totalAmount,
'currency': currency,
'ownerName': ownerName,
'shareId': shareId
}, location.tenantEmailLanguage || 'hr');
const success = await sendEmail({
to: location.tenantEmail,
subject: `Utility bills due for ${location.tenantName || 'your apartment'}`,
html
});
// Update location status
const newStatus = success ? BillsNotificationStatus.Sent : BillsNotificationStatus.Failed;
await db.collection<BillingLocation>('lokacije').updateOne(
{ _id: location._id },
{ $set: { billsNotificationStatus: newStatus } }
);
if (success) {
sentCount++;
log(`Utility bills notification sent to ${location.tenantEmail}`);
} else {
log(`Failed to send utility bills notification to ${location.tenantEmail}`);
}
budget--;
}
log(`Sent ${sentCount} utility bills notifications`);
return sentCount;
}

View File

@@ -0,0 +1,87 @@
import fs from 'fs';
import path from 'path';
import { createLogger } from './logger';
const log = createLogger('email:templates');
// Cache for loaded templates
const templateCache = new Map<string, string>();
/**
* Template variable type for type-safe template rendering
*/
export type TemplateVariables = {
[key: string]: string | number | undefined;
};
/**
* Load an email template from the templates directory
* @param templateName Name of the template file (without extension)
* @param language Language code (default: 'en')
* @returns Template content as string
*/
export function loadTemplate(templateName: string, language: string = 'hr'): string {
const cacheKey = `${templateName}--${language}`;
// Check cache first
if (templateCache.has(cacheKey)) {
log(`Using cached template: ${cacheKey}`);
return templateCache.get(cacheKey)!;
}
// Construct template file path
const templateFileName = `email-template--${templateName}--${language}.html`;
const templatePath = path.join(__dirname, '../../email-templates', templateFileName);
try {
const content = fs.readFileSync(templatePath, 'utf-8');
templateCache.set(cacheKey, content);
log(`Loaded template: ${templateFileName}`);
return content;
} catch (error) {
log(`Failed to load template ${templateFileName}: ${error}`);
throw new Error(`Template not found: ${templateFileName}`);
}
}
/**
* Render a template by replacing variables
* @param template Template content
* @param variables Object with variable values
* @returns Rendered HTML string
*/
export function renderTemplate(template: string, variables: TemplateVariables): string {
let rendered = template;
// Replace all ${variable} occurrences
for (const [key, value] of Object.entries(variables)) {
if (value !== undefined) {
const regex = new RegExp(`\\$\\{${key}\\}`, 'g');
rendered = rendered.replace(regex, String(value));
}
}
// Log warning if there are unreplaced variables
const unreplacedMatches = rendered.match(/\$\{[^}]+\}/g);
if (unreplacedMatches) {
log(`Warning: Unreplaced variables in template: ${unreplacedMatches.join(', ')}`);
}
return rendered;
}
/**
* Load and render an email template in one step
* @param templateName Name of the template file (without extension)
* @param variables Object with variable values
* @param language Language code (default: 'en')
* @returns Rendered HTML string
*/
export function loadAndRender(
templateName: string,
variables: TemplateVariables,
language: string = 'hr'
): string {
const template = loadTemplate(templateName, language);
return renderTemplate(template, variables);
}

View File

@@ -0,0 +1,8 @@
/**
* Za neinicijaliziranu env varijablu vraća default vrijednost
* @param value vrijednost env varijable
* @param defaultValue default vrijednost
* @returns
*/
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);

View File

@@ -0,0 +1,21 @@
import debug from 'debug';
/**
* Logs to console / stdout
* @param namespace
* @returns instance of Debug
*/
export const createLogger = (namespace:string):debug.Debugger => {
const dbg = debug(namespace);
const rx = /nodemon/gi;
if(rx.test(process.env?.npm_lifecycle_script ?? "")) {
// When started via nodemon:
// forcing the use of console insted of stdout
// -> nodemon doesn't work with stdout
dbg.log = console.log.bind(console);
}
return(dbg);
};

View File

@@ -0,0 +1,67 @@
import formData from 'form-data';
import Mailgun from 'mailgun.js';
import { createLogger } from './logger';
const log = createLogger("email:mailgun");
export interface EmailMessage {
to: string;
subject: string;
html: string;
}
let mailgunClient: any = null;
/**
* Initialize Mailgun client
*/
function getMailgunClient() {
if (mailgunClient) {
return mailgunClient;
}
const apiKey = process.env.MAILGUN_API_KEY;
if (!apiKey) {
throw new Error('MAILGUN_API_KEY environment variable is not set');
}
const mailgun = new Mailgun(formData);
mailgunClient = mailgun.client({
username: 'api',
key: apiKey,
url: "https://api.eu.mailgun.net"
});
return mailgunClient;
}
/**
* Send an email using Mailgun
* @param message Email message to send
* @returns True if successful, false otherwise
*/
export async function sendEmail(message: EmailMessage): Promise<boolean> {
try {
const client = getMailgunClient();
const domain = process.env.MAILGUN_DOMAIN || 'rezije.app';
const messageData = {
from: 'rezije.app <noreply@rezije.app>',
to: message.to,
subject: message.subject,
html: message.html,
"v:locationID": "12345" // testing custom variable - webhook should pick this up
};
log(`Sending email to ${message.to}: ${message.subject}`);
const response = await client.messages.create(domain, messageData);
log(`Email sent successfully to ${message.to}, ID: ${response.id}`);
return true;
} catch (error) {
log(`Failed to send email to ${message.to}: ${error}`);
return false;
}
}

View File

@@ -0,0 +1,50 @@
import { Counter, Histogram, register } from 'prom-client';
import { coalesce } from './initTools';
/** Histogram Buckets */
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, "0.1, 0.5, 1, 5, 10");
/** Labela kojom želimo da bude označena metrika prikupljena na ovom web servisu */
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, 'email-worker');
// na "app" labele ćemo razdvajanje rezultata u Grafani
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
/**
* Broji koliko je ukupno zahtjeva zaprimljeno za obradu
*/
export const totalRequestCounter = new Counter({
name: "request_operations_total",
help: "ukupan broj zaprimljenih zahtjeva",
/** countere razdvajamo po vrsti zahtjeva */
labelNames: ['path'],
});
/**
* Broji zahtjeve koji su uspješno obrađeni
*/
export const successfulRequestCounter = new Counter({
name: "request_operations_ok",
help: "broj zahtjeva koji su uspješno obrađeni",
/** countere razdvajamo po vrsti zahtjeva */
labelNames: ['path'],
});
/**
* Broji zahtjeve kod čije obrade je došlo do greške
*/
export const failedRequestCounter = new Counter({
name: "request_operations_failed",
help: "broj zahtjeva kod čije obrade je došlo do greške",
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
labelNames: ["path", "status"],
});
/** Histogram mjeri koliko traje obrada pristiglog zahtjeva */
export const requestDurationHistogram = new Histogram({
name: "request_duration_seconds",
help: "Trajanje request-a u sekundama",
/** countere razdvajamo po vrsti zahtjeva i rezultatu izvođenja */
labelNames: ["path", "status"],
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
});

View File

@@ -0,0 +1,19 @@
/**
* This function serializes an error object into a string that can be logged
* @param ex error object
* @returns string
* @description SQL Server may generate more than one error for one request so you can access preceding errors with `err.precedingErrors`, while the `ex` itself is a generic error without any useful information
*/
export const serializeError = (ex:Error | Error & { precedingErrors?:Error[] }):string => {
const { name, message, stack, precedingErrors } = (ex as Error & { precedingErrors?:Error[] });
// SQL Server may generate more than one error for one request so you can access preceding errors with `ex.precedingErrors`,
// while the `ex` itself is a generic error without any useful information
if(precedingErrors) {
return(serializeError(precedingErrors[0]));
}
return `${name}:${message}`;
}

View File

@@ -0,0 +1,81 @@
import { ErrorRequestHandler, Request, Response } from "express";
import createHttpError, { HttpError } from "http-errors";
import { createLogger } from '../lib/logger';
import { NgitLocals } from "../types/NgitLocals";
import { failedRequestCounter } from "../lib/metricsCounters";
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
const consoleLog = createLogger("server:server");
/**
* Router koji se zadnji poziva, a koji sastavlja odgovor u slučaju greške
* @param err
* @param req
* @param res
* @param next
*/
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const requestPath = req.path as SupportedRoutes;
// kako je ovaj error handler dosta složen, moguće je da negdje baci grešku
// > zato je zamotan u try-catch
// > na taj način osiguravam da neće srušiti cijeli proces
try {
let { name:errorLogName, message:errorLogText } = err;
let responseBody:string = "";
switch(err.status) {
case 400:
responseBody = 'bad request';
break;
case 401:
responseBody = 'unauthorized';
break;
case 403:
responseBody = 'forbidden';
break;
case 404:
consoleLog(`page not found ${req.method} ${requestPath}`)
responseBody = 'page not found';
errorLogText = `page ${requestPath} not found`;
break;
case 500:
responseBody = "internal server error";
errorLogText = err.message;
break;
default:
responseBody = err.name;
errorLogText = `err.status=${err.status};err.name=${err.name};err.message=${err.message}`;
}
consoleLog(`${errorLogName}:${errorLogText}`);
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
// > ako ih probam ponovo postaviti, to će baciti grešku ... a to ovdje mogu izbjeći
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(responseBody);
} else {
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
// ... u suprotnom će konekcija ostati otvorena do timeout-a
if(!res.writableEnded) {
res.end();
}
}
} catch(ex:any) {
// ovu grešku će obraditi `finalErrorRouter`
next(createHttpError(500, ex));
}
// ne mogu dopustiti da prometheus client sruši server
try {
failedRequestCounter.inc({ path: requestPath, status: err.status });
(res.locals as NgitLocals).stopPrometheusTimer({ path: req.path, status: err.status });
} catch(ex:any) {
console.error(ex);
}
};

View File

@@ -0,0 +1,34 @@
import { ErrorRequestHandler, Request, Response } from "express";
import { HttpError } from "http-errors";
import { createLogger } from '../lib/logger';
import { NgitLocals } from "../types/NgitLocals";
const consoleLog = createLogger("server:server");
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param err error objekt
* @param req express request
* @param res express response
* @param next
*/
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
consoleLog(`Server Error ${err.status}\n${errorLogText}`);
// `headersSent` će biti TRUE ako je router kod kojeg se dogodila greška već poslao header-e
// > ako ih probam ponovo postaviti, to će baciti grešku i u ovom slučaju SRUŠITI SERVER - to ne smijemo dopustiti
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(`unhandled server error`);
} else {
// AKO nije pozvan `end` - pozovi ga i završi obradu zahtjeva
// ... u suprotnom će konekcija ostati otvorena do timeout-a
if(!res.writableEnded) {
res.end();
}
}
};

View File

@@ -0,0 +1,35 @@
import { RequestHandler, Router } from "express";
import { workerRunnerInfo } from "../workRunner";
import { coalesce } from "../lib/initTools";
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
/** Maximum time between two worker jobs */
const MAX_WORKER_LATENCY = PULL_INTERVAL * 2.5;
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param req express request
* @param res express response
* @param next
*/
export const healthcheckRouter:RequestHandler = async (req, res, next) => {
const workerLatency = Date.now() - workerRunnerInfo.lastWorkTime;
if(workerLatency > MAX_WORKER_LATENCY) {
const msg = `No work done in ${workerLatency}ms. Last worker status = "${workerRunnerInfo.status}"`;
console.warn(msg)
res.status(500);
res.setHeader('Content-Type', 'text/plain');
res.end(msg);
} else {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('OK');
}
};
export const pingRouter = Router();
pingRouter.get('/', healthcheckRouter);

View File

@@ -0,0 +1,19 @@
import { Router, NextFunction, Request, Response } from "express";
import createError from 'http-errors';
import { register } from 'prom-client';
import { createLogger } from '../lib/logger';
const logger = createLogger("server:metrics");
export const metricsRouter = Router();
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
// ne mogu dopustiti da prometheus client sruši server
try {
logger(`⚡️[server]: GET /metrics`);
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch(ex:any) {
next(createError(500, (ex as Error).message));
}
});

View File

@@ -0,0 +1,16 @@
import { RequestHandler, Router } from "express";
/**
* Router koji se izvršava u slučaju grube greške koja nije obrađena nigdje prije
* @param req express request
* @param res express response
* @param next
*/
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('PONG');
};
export const pingRouter = Router();
pingRouter.get('/', pingRequestHandler);

View File

@@ -0,0 +1,7 @@
import { LabelValues } from "prom-client";
/** data assignet to `express.response.locals` */
export type NgitLocals = {
/** Prometheus client timer */
stopPrometheusTimer: (labels?: LabelValues<"path"|"status">) => number,
};

View File

@@ -0,0 +1,5 @@
export enum SupportedRoutes {
metricsPath='/metrics',
ping='/ping',
healthcheck='/healthcheck',
}

60
email-worker/src/types/environment.d.ts vendored Normal file
View File

@@ -0,0 +1,60 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
/**
* (optional) environment u kojem se proces vrti
* @default undefined
* */
ENV?:"dev"|"jest"
/**
* (optional) App label to be used in Prometheus (Grafana)
* @default "email-worker"
* */
PROMETHEUS_APP_LABEL?: string
/**
* (optional) Prometheus histogram bucket sizes (grafana)
* @default "0.1, 0.5, 1, 5, 10"
* */
PROMETHEUS_HISTOGRAM_BUCKETS?: string
/**
* (required) Pull interval in milliseconds - how often should worker cycle run
* @default "10000"
* */
PULL_INTERVAL:string
/**
* (required) MongoDB connection URI
* */
MONGODB_URI: string
/**
* (required) Mailgun API key for sending emails
* */
MAILGUN_API_KEY: string
/**
* (optional) Mailgun domain
* @default "rezije.app"
* */
MAILGUN_DOMAIN?: string
/**
* (required) Secret key for generating share link checksums
* */
SHARE_LINK_SECRET: string
/**
* (optional) Maximum number of emails to send per worker run
* @default "10"
* */
EMAIL_BUDGET?: string
/**
* (optional) HTTP server port
* @default "3000"
* */
PORT?: string
/**
* (optional) Debug namespaces for console logging
* */
DEBUG?: string
}
}
}
export {}

View File

@@ -0,0 +1,165 @@
import { failedRequestCounter, requestDurationHistogram, successfulRequestCounter, totalRequestCounter } from "./lib/metricsCounters";
import { coalesce } from "./lib/initTools";
import { createLogger } from "./lib/logger";
import { serializeError } from "./lib/serializeError";
import { doWork } from "./emailWorker";
/** time between two pull operations */
const PULL_INTERVAL = parseInt(coalesce(process.env.PULL_INTERVAL, "10000"));
const consoleLog = createLogger("server:server");
/** Writes entry to log */
const logWrite = (logTitle:string, logMessage:string) => {
consoleLog(`${logTitle}: ${logMessage}}`);
}
/** Writes error to log */
const logError = (ex: any) =>
logWrite(serializeError(ex), "error");
/**
* zastavica za zaustavljanje sinhronizacije
*/
let disposed:boolean = false;
/** is worker started - prevents multiple starts */
let workerStarted:boolean = false;
/** Promise which is resolved once the pending work in progress is completed */
let pendingWork:Promise<void>|undefined;
/** Worker re-run timeout */
let pendingTimeout:NodeJS.Timeout|undefined;
/** Enumeracija pojedinih statusa obrade jednog work-a */
export enum WorkerRunnerStatus {
init="init",
disposed="disposed",
beginWork="beginWork",
updatedStats1="updatedStats1",
updatedStats2="updatedStats2",
stoppedStatTimer="stoppedStatTimer",
workDone="workDone",
newIntervalScheduled="newIntervalScheduled",
currentWorkResolved="currentWorkResolved",
}
/** Info o statusu workera */
export type WorkerRunnerInfo = {
/** zadnje izvršena readnja */
status: WorkerRunnerStatus,
/** vrijeme kada je worker zadnji puta pokrenut */
lastWorkTime: number,
}
/** Info o statusu workera, koji koristi healthcheck kako bi vidio da li stvar funkcionira */
export const workerRunnerInfo:WorkerRunnerInfo = {
status: WorkerRunnerStatus.init,
lastWorkTime: Date.now()
}
export const workRunner = async () => {
pendingTimeout = undefined;
workerRunnerInfo.lastWorkTime = Date.now();
workerRunnerInfo.status = WorkerRunnerStatus.beginWork;
// AKO je modul zaustavljen
// -> nemoj se pokrenuti
if(disposed) {
workerRunnerInfo.status = WorkerRunnerStatus.disposed;
return;
}
// kreiram Promise koji omogućuje da dispose zna
// pričekati da worker završi sa poslom (ako je u tom trenutku aktivan)
pendingWork = new Promise(async (resolve) => {
try {
totalRequestCounter.inc();
const stopPrometheusTimer = requestDurationHistogram.startTimer();
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats1;
try {
// ne dopuštam da stvar sruši worker
await doWork();
workerRunnerInfo.status = WorkerRunnerStatus.workDone;
// ažuriram statistiku
successfulRequestCounter.inc();
workerRunnerInfo.status = WorkerRunnerStatus.updatedStats2;
} catch(ex:any) {
// ažuriram statistiku
failedRequestCounter.inc();
logError(ex);
}
stopPrometheusTimer();
workerRunnerInfo.status = WorkerRunnerStatus.stoppedStatTimer;
} catch(ex:any) {
logError(ex);
}
// nemoj pokrenuti timer ako je worker u međuvremenu disposed
if(!disposed) {
// pull again after timeout
pendingTimeout = setTimeout(workRunner, PULL_INTERVAL);
workerRunnerInfo.status = WorkerRunnerStatus.newIntervalScheduled;
} else {
logWrite("Info", "... exiting worker loop");
}
resolve();
workerRunnerInfo.status = WorkerRunnerStatus.currentWorkResolved;
pendingWork = undefined;
});
// this is an async function which must return a promise
// > so return the promise which will be resolved once the work is done
return(pendingWork);
};
/**
* Starts the worker
*/
export const startSyncWorker = () => {
if(!workerStarted && !disposed) {
workerStarted = true;
workRunner();
logWrite("Info", "Worker Started");
};
}
/**
* Stops and disposes the worker
*/
export const disposeSyncWorker = async () => {
logWrite("Info", "Disposing worker ...");
disposed = true;
// preventing timer from trigger another work cycle
if(pendingTimeout) {
clearTimeout(pendingTimeout);
}
// IF no work is currently in progress
// > return a resolved promise
if(!pendingWork) {
return(Promise.resolve());
}
await pendingWork;
logWrite("Info", "Worker disposed!");
}
/** Ovo se koristi samo za Unit Testing */
export const reset_dispose = () => {
disposed = false;
}

View File

@@ -0,0 +1,29 @@
import { LabelValues } from "prom-client";
export class Counter {
public inc() {
}
}
export class Histogram<T extends string> {
startTimer(labels?: LabelValues<T>): (labels?: LabelValues<T>) => void {
return((labels?: LabelValues<T>) => { });
}
}
class Register {
public setDefaultLabels(labels: Object) {
}
public metrics(): Promise<string> {
return(Promise.resolve(""));
}
public get contentType() {
return("");
}
}
export const register = new Register();

View File

@@ -0,0 +1,33 @@
import { Request, Response, NextFunction } from 'express';
import { NgitLocals } from "../../src/types/NgitLocals";
interface IMockHttpContext {
reqPath?:string
headersSent?:boolean
writableEnded?:boolean
method?:string
}
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET"}:IMockHttpContext|undefined = {}) => {
const req = {
path:reqPath,
method,
url:`https://localhost${reqPath}`,
params: {},
} as unknown as Request;
const res = {
end: jest.fn(),
status: jest.fn(),
setHeader: jest.fn(),
locals: {
stopPrometheusTimer: jest.fn(),
} as unknown as NgitLocals,
headersSent,
writableEnded,
} as unknown as Response;
const next:NextFunction = jest.fn();
return({req,res,next})
}

View File

@@ -0,0 +1,118 @@
import { errorRouter } from '../../src/routes/errorRouter';
import createError from "http-errors";
import { mockHttpContext } from "../helpers/mockHttpContext";
describe("errorRouter", () => {
beforeEach(() => {
mockWrite.mockClear();
});
test("u slučaju greške 404 mora vratiti string poruku 'page not found'", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("page not found");
});
test("u slučaju greške 404 mora logirati request, response i tekst greške", async () => {
const err = createError(404)
const reqPath = "/neki-path/";
const {req,res,next} = mockHttpContext({ reqPath });
await errorRouter(err, req, res, next);
expect(res.locals.logger.info).toHaveBeenCalledWith("response", "page not found");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, "page "+req.path+" not found");
});
test("ako su header-i već poslani, ne smiju biti poslani još jednom", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:true });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
test("ako NIJE već pozvana [end] metoda, treba je pozvati", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).toHaveBeenCalled();
});
test("mora zaustaviti Prometheus Timer", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.locals.stopPrometheusTimer).toHaveBeenCalled();
});
test("u slučaju greške 500 mora vratiti string poruku 'internal server error'", async () => {
const err = createError(500)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("internal server error");
});
test("u slučaju greške 400 mora vratiti string poruku 'bad request' i logirati grešku", async () => {
const errorMessage = "mock error text 1";
const err = createError(400, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("bad request");
expect(res.locals.logger.errorwrite).toHaveBeenCalledWith(err.name, errorMessage);
});
test("u slučaju greške 401 mora vratiti string poruku 'unauthorized' i logirati grešku", async () => {
const errorMessage = "mock error text 2";
const err = createError(401, errorMessage)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("unauthorized");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
});
test("u slučaju greške 403 mora vratiti string poruku 'forbidden' i logirati grešku", async () => {
const errorMessage = "mock error text 3";
const err = createError(403, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("forbidden");
expect(res.locals.logger.error).toHaveBeenCalledWith(err.name, errorMessage);
});
});

View File

@@ -0,0 +1,36 @@
{
"compilerOptions": {
"target": "es2020", // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
"module": "commonjs",
"esModuleInterop": true, // solves the problem regarding the default importa vs importa *
"strict": true,
"sourceMap": true, // please do create source maps
"skipLibCheck": true, // don't verify typescript of 3rd party modules
"rootDir": "src", // root directory under which source files are located - it's subtree will be mirrored in "outDir"
"outDir": "build", // where the build files should be stored
// "baseUrl" ---- se NE SMIJE koristiti
// POJAŠNJENJE: ako zadamo "baseUrl" Intellisense će početi kod autocompletion-a (Ctrl+Space)
// umjesto relativnih insertirati apsolutni path do modula,
// a takav path nije dobar za build niti debugging
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
"paths": {
},
"plugins": [
{
// Slijedeće je namijenjeno BUILD projekta
// POJAŠNJENJE: build tadi `ttypescript`
// koji ne zna interpretirati što je podešeno pod "path"
// > to za njega rješava "typescript-transform-paths"
"transform": "typescript-transform-paths"
}
]
},
"include": ["src/**/*"], // location of files which need to be compiled
// Slijedeće je namijenjeno DEBUGGING servera u VS Code-u
// POJAŠNJENJE: kod debugginga modul se pokreće pomoću `ts-node`,
// koji ne zna sam interpretirati što je podešeno pod "paths"
// > to za njega rješava "tsconfig-paths/register"
"ts-node": {
"require": ["tsconfig-paths/register"]
},
}

View File

@@ -17,8 +17,12 @@
"path": "mailgun-webhook"
},
{
"name": "⚙️ email-server-worker",
"path": "email-server-worker"
"name": "⚙️ email-worker",
"path": "email-worker"
},
{
"name": "🔗 shared-code",
"path": "shared-code"
},
{
"name": "📦 root",

View File

@@ -0,0 +1,7 @@
tests
.git
coverage
node_modules
jest.config.ts
service-tester.sh
build-image.sh

5
mailgun-webhook/.env Normal file
View File

@@ -0,0 +1,5 @@
# This file defines enviroment variables used in development environment
# It will be ommited from Docker image by the build process
# in dev environment Web6 app uses port 3000, so we need to make sure we use different port
PORT="4000"

View File

@@ -0,0 +1,26 @@
# Environment Variables for MailGun Webhook Service
# Copy this file to .env for local development
# Server Configuration
PORT=3000
# Prometheus Monitoring
# Label that will mark the metric in Prometheus (default: package.json name)
PROMETHEUS_APP_LABEL=mailgun-webhook-service
# CSV definition of buckets for Prometheus/Grafana histogram
PROMETHEUS_HISTOGRAM_BUCKETS=0.1,0.5,1,5,10
# Debug Logging
# Enable debug output for specific namespaces
# Examples: server:*, app:*, or * for all
DEBUG=server:*,app:*
# MailGun Configuration (for future enhancements)
# Uncomment and configure when adding webhook signature verification
# MAILGUN_SIGNING_KEY=your-mailgun-signing-key
# MAILGUN_WEBHOOK_TIMEOUT=30000
# Security Configuration (optional)
# Uncomment to restrict webhook access to MailGun IPs only
# ALLOWED_IPS=209.61.151.0/24,209.61.154.0/24,173.193.210.0/24

3
mailgun-webhook/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
node_modules
build
coverage/

19
mailgun-webhook/.mcp.json Normal file
View File

@@ -0,0 +1,19 @@
{
"mcpServers": {
"serena": {
"command": "uvx",
"args": [
"--from",
"git+https://github.com/oraios/serena",
"serena",
"start-mcp-server",
"--enable-web-dashboard",
"false"
]
},
"context7": {
"type": "http",
"url": "https://mcp.context7.com/mcp"
}
}
}

61
mailgun-webhook/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,61 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug Server",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"skipFiles": [
"<node_internals>/**"
],
"runtimeExecutable": "npm",
"runtimeArgs": [
"run-script",
"start"
],
"sourceMaps": true,
"env": {
"DEBUG": "*"
}
},
{
"type": "node",
"name": "vscode-jest-tests",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
{
"type": "node",
"name": "vscode-jest-tests-1634200842588",
"request": "launch",
"envFile": "${workspaceFolder}/.env",
"console": "integratedTerminal",
"internalConsoleOptions": "neverOpen",
"cwd": "${workspaceFolder}",
"runtimeExecutable": "npm",
"args": [
"run",
"test",
"--",
"--runInBand",
"--watchAll=false"
]
},
]
}

9
mailgun-webhook/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"jest.jestCommandLine": "npm run test --",
"jest.autoRun": {
"watch": false,
"onSave": "test-file"
},
"jest.nodeEnv": {
}
}

View File

@@ -0,0 +1,66 @@
#--------------------------------------------
# Stage: building TypeScript
#--------------------------------------------
FROM node:20 AS build-stage
ENV WORKDIR=/app
WORKDIR /app
COPY ./package*.json ./
# instaliram pakete
RUN npm i && npm cache clean --force
COPY ./tsconfig.json ./
COPY ./src ./src
RUN npm run build
#--------------------------------------------
# Stage: installing production node_modules
#--------------------------------------------
FROM node:20 AS package-stage
WORKDIR /app
COPY ./package*.json ./
# install ONLY production dependencies
RUN npm i --omit=dev && npm cache clean --force
#--------------------------------------------
# Stage: preparing final image
#--------------------------------------------
FROM gcr.io/distroless/nodejs20-debian12:nonroot AS assembly-stage
WORKDIR /app
ARG PORT
ENV PORT=${PORT}
# (optional) App label to be used in Prometheus (Grafana)
ARG PROMETHEUS_APP_LABEL
ENV PROMETHEUS_APP_LABEL=${PROMETHEUS_APP_LABEL}=${PROMETHEUS_APP_LABEL}
# (optional) Prometheus histogram bucket sizes (grafana)
ARG PROMETHEUS_HISTOGRAM_BUCKETS
ENV PROMETHEUS_HISTOGRAM_BUCKETS=${PROMETHEUS_HISTOGRAM_BUCKETS}=${PROMETHEUS_HISTOGRAM_BUCKETS}
# (optional) enables logging to stdout
ARG DEBUG
ENV DEBUG=${DEBUG}
# copying node_modules
COPY --from=package-stage /app/package*.json ./
COPY --from=package-stage /app/node_modules ./node_modules
# copying built files
COPY --from=build-stage /app/build ./server
# running the server under limited "nobody" user
USER nobody:nobody
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
CMD ["/nodejs/bin/node", "./server/healthcheck.js"]
# starting the server
CMD ["./server/entry.js"]

View File

@@ -1,23 +1,185 @@
# Mailgun Webhook Handler
# MailGun Webhook Service
This workspace contains the Mailgun webhook handler service for processing email events related to the Evidencija Režija tenant notification system.
A production-ready TypeScript/Express.js service for receiving and logging MailGun webhook events with:
- **Webhook Processing**: Handles all MailGun email event types (delivered, failed, opened, clicked, etc.)
- **Structured Logging**: Console logging with detailed event information
- **Monitoring**: Built-in Prometheus metrics and health checks
- **Testing**: Complete Jest test suite with comprehensive webhook event coverage
- **Docker Ready**: Containerized deployment with health monitoring
## Purpose
## Features
This service handles email verification and status updates by:
- Detecting new tenant email addresses (EmailStatus.Unverified)
- Sending verification emails via Mailgun
- Updating email status to VerificationPending
- Processing webhook events from Mailgun (bounces, complaints, etc.)
### 📧 MailGun Webhook Integration
- **Event Types**: Supports all MailGun webhook events:
- `delivered` - Email successfully delivered
- `failed` - Delivery failed (temporary or permanent)
- `opened` - Email opened by recipient
- `clicked` - Link clicked in email
- `bounced` - Email bounced back
- `complained` - Recipient marked as spam
- `unsubscribed` - Recipient unsubscribed
- **Data Logging**: Comprehensive console logging with structured formatting
- **Type Safety**: Full TypeScript definitions for all event types
## Architecture
### 🏗️ Infrastructure
- **TypeScript**: Full type safety and modern JavaScript features
- **Express.js**: Fast, minimalist web framework
- **Logging**: Structured logging with debug support and detailed event formatting
- **Health Checks**: Built-in `/ping` endpoint for monitoring
This is a separate system from the Next.js web-app that communicates via the shared MongoDB database.
### 📊 Monitoring & DevOps
- **Prometheus Metrics**: Built-in metrics collection at `/metrics`
- **Docker**: Complete containerization setup
- **Source Maps**: Debugging support for production
- **Hot Reload**: Development server with auto-restart
## Setup
### 🧪 Testing & Quality
- **Jest**: Comprehensive unit tests for all webhook event types
- **TypeScript**: Full type coverage
- **Test Structure**: Mirror source structure for easy navigation
- **CI Ready**: Tests configured for continuous integration
TBD
## Architecture Overview
## Environment Variables
```
src/
├── entry.ts # Application bootstrap
├── app.ts # Express app configuration
├── routes/
│ ├── webhookRouter.ts # MailGun webhook handler
│ ├── pingRouter.ts # Health check endpoint
│ ├── metricsRouter.ts # Prometheus metrics
│ └── errorRouter.ts # Error handling
├── middleware/
│ └── InitLocalsMiddleware.ts # Request context initialization
├── types/
│ ├── MailgunWebhookEvent.ts # MailGun event type definitions
│ └── enums/SupportedRoutes.ts # Route path constants
└── lib/
├── logger.ts # Structured logging utilities
└── metricsCounters.ts # Prometheus metrics definitions
```
TBD
**API Endpoints:**
- `POST /webhook` - Receives MailGun webhook events
- `GET /ping` - Health check endpoint
- `GET /metrics` - Prometheus metrics
For detailed API specification, see [docs/MAILGUN_WEBHOOK_API_SPEC.md](docs/MAILGUN_WEBHOOK_API_SPEC.md).
## Quick Start
### 1. Installation
```bash
# Install dependencies
npm install
# Create environment file
cp .env.example .env
# Edit .env if needed (defaults work for development)
```
### 2. Development
```bash
# Start development server with hot reload
npm start
# Server will be running at http://localhost:3000
```
### 3. Testing the Webhook
```bash
# Send a test webhook event
curl -X POST http://localhost:3000/webhook \
-H "Content-Type: application/json" \
-d '{
"event": "delivered",
"timestamp": "1234567890",
"token": "test-token",
"signature": "test-signature",
"recipient": "user@example.com",
"domain": "mail.example.com"
}'
# Check the console output for logged event data
```
### 4. Running Tests
```bash
# Run tests in watch mode
npm test
# Run tests once with coverage
npm run test:ci
# Type checking
npm run type-check
```
### 5. Production Build
```bash
# Build TypeScript to JavaScript
npm run build
# Run production server
npm run run-server
```
## Configuring MailGun
To start receiving webhook events from MailGun:
1. **Log in to your MailGun account**
2. **Navigate to Sending → Webhooks**
3. **Add a new webhook URL**: `https://your-domain.com/webhook`
4. **Select event types** you want to receive (or select all)
5. **Save the webhook configuration**
MailGun will start sending events to your service endpoint immediately.
### Webhook Security (Future Enhancement)
For production deployment, you should implement webhook signature verification:
- Use MailGun's `timestamp`, `token`, and `signature` fields
- Verify the signature using your MailGun signing key
- See [MailGun Webhook Security Documentation](https://documentation.mailgun.com/en/latest/user_manual.html#webhooks)
## Docker Deployment
### Building the Docker Image
```bash
# Build the image
./build-image.sh 1.0.0
# The image will be tagged as mailgun-webhook-service:1.0.0
```
### Running with Docker
```bash
# Run the container
docker run -d \
-p 3000:3000 \
-e PORT=3000 \
-e PROMETHEUS_APP_LABEL=mailgun-webhook-service \
--name mailgun-webhook \
mailgun-webhook-service:1.0.0
# Check logs
docker logs -f mailgun-webhook
```
## Monitoring
The service exposes several monitoring endpoints:
- **Health Check**: `GET /ping` - Returns "pong" if service is healthy
- **Prometheus Metrics**: `GET /metrics` - Prometheus-compatible metrics
## Documentation
- 📧 **[MailGun Webhook API Spec](docs/MAILGUN_WEBHOOK_API_SPEC.md)** - Complete API specification
## License
ISC

70
mailgun-webhook/build-image.sh Executable file
View File

@@ -0,0 +1,70 @@
#!/bin/bash
# Parse flags
AUTO_VERSION=false
AUTO_PUSH=false
IMAGE_VERSION=""
for arg in "$@"; do
case $arg in
--auto-version)
AUTO_VERSION=true
;;
--auto-push)
AUTO_PUSH=true
;;
*)
if [ "$IMAGE_VERSION" == "" ] && [[ ! "$arg" =~ ^-- ]]; then
IMAGE_VERSION=$arg
fi
;;
esac
done
# Determine version
if [ "$AUTO_VERSION" = true ]; then
IMAGE_VERSION=$(node -p "require('./package.json').version")
printf "\nAuto-version enabled. Using version from package.json: %s\n" "$IMAGE_VERSION"
elif [ "$IMAGE_VERSION" == "" ]; then
printf "\nYou did not specify the Docker image version to build"
printf "\n\nSyntax:\n\n build-image.sh <version> [--auto-push]"
printf "\n build-image.sh --auto-version [--auto-push]\n\n"
exit 1
fi
REGISTRY_URL="registry.budakova.org"
IMAGE_NAME=$(node -p "require('./package.json').name")
IMAGE_TAG=$REGISTRY_URL/$IMAGE_NAME:$IMAGE_VERSION
# Check if image already exists in registry (only when using auto-version)
if [ "$AUTO_VERSION" = true ]; then
printf "\nChecking if image %s already exists in registry...\n" "$IMAGE_TAG"
if docker manifest inspect $IMAGE_TAG > /dev/null 2>&1; then
printf "\nERROR: Image %s already exists in registry.\n" "$IMAGE_TAG"
printf "Please update the version in package.json before building.\n\n"
exit 1
fi
printf "Image does not exist in registry. Proceeding with build.\n"
fi
# Check for push preference
if [ "$AUTO_PUSH" = true ]; then
PUSH_IMAGE="y"
printf "\nAuto-push enabled. Image will be pushed to registry.\n"
else
read -p "BUILD: Push new image to registry [y/n]? " -n 1 -r
echo # (optional) move to a new line
PUSH_IMAGE="$REPLY"
fi
printf "\nBUILD START ...\n\n"
docker build . -t $IMAGE_TAG
if [[ "$PUSH_IMAGE" =~ ^[Yy]$ ]]
then
printf "\nPushing image ...\n\n"
docker push $IMAGE_TAG
fi
printf "\nBUILD DONE!\n\n"

View File

@@ -0,0 +1,176 @@
# MailGun Webhook API Specification
## Overview
This document specifies the API for receiving webhook events from MailGun. The service logs all received event data to the console for monitoring and debugging purposes.
## API Endpoints
### POST /webhook
Receives webhook events from MailGun when email events occur.
#### Request Format
- **Method**: POST
- **Content-Type**: `application/x-www-form-urlencoded` or `multipart/form-data`
- **Headers**:
- No custom headers required for initial implementation
#### Request Parameters
MailGun sends various parameters depending on the event type. Common parameters include:
**Event Identification:**
- `event` (string) - Type of event (delivered, failed, opened, clicked, bounced, complained, unsubscribed)
- `timestamp` (number) - Unix timestamp when the event occurred
- `token` (string) - Randomly generated string for message signature verification
- `signature` (string) - String with hexadecimal digits for signature verification
**Message Information:**
- `message-id` (string) - MailGun message ID
- `recipient` (string) - Email address of the recipient
- `domain` (string) - Domain from which the email was sent
- `Message-Id` (string) - SMTP Message-ID header
**Event-Specific Parameters:**
For **delivered** events:
- `message-headers` (string) - JSON string of message headers
For **failed** events:
- `severity` (string) - Severity level (temporary/permanent)
- `reason` (string) - Reason for failure
- `notification` (string) - Detailed notification message
For **opened** events:
- `city` (string) - City where email was opened
- `country` (string) - Country code
- `device-type` (string) - Device type (desktop/mobile/tablet)
- `client-os` (string) - Operating system
- `client-name` (string) - Email client name
- `ip` (string) - IP address
For **clicked** events:
- `url` (string) - URL that was clicked
- `city`, `country`, `device-type`, `client-os`, `client-name`, `ip` - Same as opened events
For **bounced** events:
- `code` (string) - SMTP error code
- `error` (string) - Detailed error message
- `notification` (string) - Bounce notification
For **complained** events:
- No additional parameters
For **unsubscribed** events:
- No additional parameters
#### Success Response
- **HTTP Status**: 200 OK
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"status": "received",
"message": "Webhook event logged successfully"
}
```
#### Error Responses
**Invalid Request (400 Bad Request)**:
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"error": "Invalid request format"
}
```
**Server Error (500 Internal Server Error)**:
- **Content-Type**: `application/json`
- **Response Body**:
```json
{
"error": "Internal server error"
}
```
## Execution Flow
1. **Receive webhook POST request** from MailGun
2. **Parse request body** (form-urlencoded or multipart data)
3. **Extract event data** from request parameters
4. **Log event data to console** with structured formatting:
- Event type
- Timestamp (both Unix and human-readable)
- Recipient
- All additional event-specific parameters
5. **Return success response** to MailGun
## Edge Cases
### Missing Event Type
- **Detection**: Check if `event` parameter is present
- **Handling**: Log warning and return 400 Bad Request
### Malformed Timestamp
- **Detection**: Check if `timestamp` can be parsed as number
- **Handling**: Log with current timestamp instead, continue processing
### Large Payload
- **Detection**: Monitor request body size
- **Handling**: Log truncated data if exceeds reasonable size
### Duplicate Events
- **Detection**: MailGun may send duplicate webhooks
- **Handling**: Log all events (no deduplication in initial implementation)
## Security Considerations
### Future Enhancements
For production deployment, consider:
- **Signature Verification**: Verify webhook authenticity using `timestamp`, `token`, and `signature`
- **IP Whitelisting**: Restrict to MailGun's IP ranges (implemented via CloudFlare)
- **Rate Limiting**: Prevent abuse
## Database Integration
- **Current Implementation**: No database operations required
- **Future Enhancement**: Store events in database for analysis
## Third-Party API Calls
- **Current Implementation**: No third-party API calls
- **Future Enhancement**: Could integrate with notification services
## Logging Format
Console output format:
```
========================================
MailGun Webhook Event Received
========================================
Event Type: delivered
Timestamp: 1234567890 (2024-01-01 12:00:00 UTC)
Recipient: user@example.com
Domain: mail.example.com
Message ID: &lt;20240101120000.1.ABC123@mail.example.com&gt;
----------------------------------------
Additional Parameters:
{
"message-headers": "[...]",
"token": "...",
"signature": "..."
}
========================================
```
## Implementation Notes
- Use Express body-parser middleware for form data parsing
- All logging should use structured logger (debug package)
- Maintain type safety with TypeScript interfaces for event data
- Follow template's error handling patterns

View File

@@ -0,0 +1,38 @@
/** @type {import('jest/dist/types').InitialOptionsTsJest} */
import type { Config } from 'jest/build/index';
const config:Config = {
// preset: 'ts-jest',
transform: {
'^.+\\.tsx?$': [
'esbuild-jest', {
sourcemap:true, // bez ovog VS code umjesto originala prikazuje transpilirane datoteke
target:'es2020' // ovo je nužno kako bi BigInt funkcionirao
}]
},
maxWorkers: 4,
testEnvironment: 'node',
moduleNameMapper: {
},
// The root directory that Jest should scan for tests and modules within
rootDir: "./",
// A list of paths to directories that Jest should use to search for files in
roots: [
"<rootDir>/tests",
],
// The glob patterns Jest uses to detect test files
testMatch: [
"**/?(*.)+(spec).[tj]s?(x)",
],
// Automatically clear mock calls and instances between every test
clearMocks: true,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
transformIgnorePatterns: ["/node_modules/"],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
testPathIgnorePatterns: ["/node_modules/", "/build/"],
// Indicates whether each individual test should be reported during the run
verbose: true,
};
module.exports = config;

9828
mailgun-webhook/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
{
"name": "mailgun-webhook-service",
"version": "1.0.1",
"description": "MailGun webhook receiver service for logging email event notifications",
"main": "entry.ts",
"scripts": {
"start": "nodemon ./src/entry.ts",
"build": "ttsc --project ./",
"test": "jest --watch",
"test:ci": "jest --ci --coverage --watchAll=false",
"test:coverage": "jest --coverage",
"lint": "tsc --noEmit",
"type-check": "tsc --noEmit",
"run-server": "DEBUG=* node --enable-source-maps ./build/entry.js"
},
"author": "Nikola",
"license": "ISC",
"dependencies": {
"debug": "^2.6.9",
"express": "^4.18.2",
"http-errors": "^1.7.2",
"prom-client": "^14.0.1",
"stoppable": "^1.1.0"
},
"devDependencies": {
"@babel/preset-typescript": "^7.18.6",
"@types/debug": "^4.1.7",
"@types/express": "^4.17.13",
"@types/http-errors": "^1.8.1",
"@types/jest": "^29.2.5",
"@types/node": "^16.10.2",
"@types/stoppable": "^1.1.1",
"@types/supertest": "^2.0.11",
"@zerollup/ts-transform-paths": "^1.7.18",
"esbuild": "^0.16.14",
"esbuild-jest": "^0.5.0",
"jest": "^29.3.1",
"nodemon": "^2.0.13",
"supertest": "^6.3.3",
"ts-node": "^10.9.1",
"tsconfig-paths": "^4.1.2",
"ttypescript": "^1.5.15",
"typescript": "^4.9.4",
"typescript-transform-paths": "^3.4.4"
},
"babel": {
"presets": [
"@babel/preset-typescript"
]
}
}

View File

@@ -0,0 +1,49 @@
import express from 'express';
import createError from 'http-errors';
import { errorRouter } from './routes/errorRouter';
import { finalErrorRouter } from './routes/finalErrorRouter';
import { metricsRouter } from './routes/metricsRouter';
import { SupportedRoutes } from './types/enums/SupportedRoutes';
import { pingRouter } from './routes/pingRouter';
import { InitLocalsMiddleware } from './middleware/InitLocalsMiddleware';
import { webhookRouter } from './routes/webhookRouter';
const app = express();
app.disable('x-powered-by');
// in case the server runs behind a proxy
// this flag will force Express to get information such as
// client IP address, protocol from X-Forward-*
// HTTP header fields, which are set by the proxy
app.set('trust proxy', true);
// Parse URL-encoded bodies (for MailGun webhook form data)
app.use(express.urlencoded({ extended: true }));
// Parse JSON bodies (in case MailGun sends JSON)
app.use(express.json());
// Middleware that initializes Locals
app.use(InitLocalsMiddleware);
// MailGun webhook endpoint
app.use(SupportedRoutes.webhook, webhookRouter);
// prometheus fetches the latest valid statistics from this route
app.use(SupportedRoutes.metricsPath, metricsRouter);
app.use(SupportedRoutes.ping, pingRouter);
// default handler
app.use((req, res, next) => next(createError(404)));
// error handler for all expected errors
app.use(errorRouter);
// error router for unexpected errors
app.use(finalErrorRouter);
export default app;

111
mailgun-webhook/src/entry.ts Executable file
View File

@@ -0,0 +1,111 @@
import app from './app';
import http from 'http';
import stoppable from 'stoppable';
import { logServer } from './lib/logger';
/**
* Normalize a port into a number, string, or false.
*/
const normalizePort = (val:string):string|number|boolean => {
const port = parseInt(val, 10);
if (isNaN(port)) {
// named pipe
return val;
}
if (port >= 0) {
// port number
return port;
}
return false;
};
/**
* Event listener for HTTP server "error" event.
*/
const onError = (error:any):void => {
if (error.syscall !== 'listen') {
throw error;
}
const bind = typeof port === 'string'
? 'Pipe ' + port
: 'Port ' + port;
// handle specific listen errors with friendly messages
switch (error.code) {
case 'EACCES':
console.error(bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
console.error(bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
const onListening = ():void => {
const addr = server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr?.port;
logServer(`server is running at ${bind}`);
};
/**
* Get port from environment and store in Express.
*/
const port:number|string|boolean = normalizePort(process.env.PORT || '3000');
/**
* How long should stoppable wait before it starts force-closing connections
* @description wait max 10 seconds - needs to be shorter than `healthcheck.timeout` (=15sec)
*/
const FORCE_STOP_TIMEOUT = 10000;
/**
* Create HTTP server.
*/
const server = stoppable( http.createServer(app), FORCE_STOP_TIMEOUT );
// Listen on provided port, on all network interfaces.
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);
// quit on ctrl-c when running docker in terminal
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGINT', () => {
logServer('got SIGINT (aka ctrl-c in docker). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// quit properly on docker stop
// (signal neće biti registriran ako je server pokrenuti via `npm` ili `nodemon` - mora biti pokrenuti izravno via Node)
process.on('SIGTERM', () => {
logServer('got SIGTERM (docker container stop). Graceful shutdown ', new Date().toISOString());
shutdown();
});
// shut down server
const shutdown = () => {
// NOTE: server.close is for express based apps
// If using hapi, use `server.stop`
server.close((err) => {
if (err) {
console.error(err);
process.exitCode = 1;
} else {
logServer('Exiting server process...');
}
process.exit();
});
};

View File

@@ -0,0 +1,28 @@
import { logHealthCheck } from "./lib/logger";
import http, { IncomingMessage } from "http";
const options = {
host: "localhost",
port: "3000",
timeout: 2000,
path: '/ping/'
};
const request = http.request(options, (res:IncomingMessage) => {
logHealthCheck(`STATUS ${res.statusCode}`);
if (res.statusCode == 200) {
process.exit(0);
} else {
process.exit(1);
}
});
request.on("error", function (err:any) {
logHealthCheck("ERROR");
process.exit(1);
});
request.end();

View File

@@ -0,0 +1,38 @@
import { Request } from 'express';
/**
* Determines the client's IP address
* @param req HTTP request object
* @returns client's IP address
*/
export const getClientIP = (req: Request):string => {
// CloudFlare performs NAT and forwards origin IP address in HTTP header
const CF_Connecting_IP:string|undefined|string[] = req.headers["cf-connecting-ip"];
// 1. priority = CloudFlare - handles connections from external clients
if(CF_Connecting_IP) {
return(CF_Connecting_IP as string);
}
// Fortigate gateway/SSL offloader do NAT, so original client IP address is
// not directly available. Instead it is forwarded via a HTTP header
const X_Forwarded_For:string|undefined|string[] = req.headers["x-forwarded-for"];
// 2. priority = X_Forwarded_For (added by Fortigate gateway)
// handles case when the traffic is not going through CF (e.g. local traffic going through FGT load balancer)
if(X_Forwarded_For) {
return(X_Forwarded_For as string);
}
// IP address of the client connected to the express server (e.g. reverse proxy or dev machine)
const direct_client_IP:string = req.ip as string;
// the local machine's IP address may be in IPv6 format
if(direct_client_IP.substr(0,7) === '::ffff:') {
return(direct_client_IP.substr(7)); // vraćam IPv4 adresu
}
// 3. priority = direct_client_IP - useful for local testing on DEV machine
return(direct_client_IP);
};

View File

@@ -0,0 +1,8 @@
/**
* Returns the provided value if it's defined and not empty, otherwise returns the default value.
*
* @param value - The value to check (can be string or undefined)
* @param defaultValue - The default value to return if value is undefined or empty string
* @returns The original value if it's defined and not empty, otherwise the default value
*/
export const coalesce = (value:string|undefined, defaultValue:string):string => value===undefined ? defaultValue : (value==="" ? defaultValue : value);

View File

@@ -0,0 +1,68 @@
import debug from 'debug';
/**
* Creates a debug logger instance with nodemon compatibility.
*
* @param namespace - Logger namespace for filtering output
* @returns Debug logger instance configured for the environment
*/
const createLogger = (namespace:string):debug.Debugger => {
const dbg = debug(namespace);
const nodemonRegex = /nodemon/gi;
if(nodemonRegex.test(process.env?.npm_lifecycle_script ?? "")) {
// When running via nodemon, force console output instead of stdout
// This ensures logs are visible since nodemon doesn't handle stdout properly
dbg.log = console.log.bind(console);
}
return(dbg);
};
const serverLogger = createLogger('server:server');
const healthCheckLogger = createLogger('server:healthcheck');
const errorLogger = createLogger('app:error');
const warnLogger = createLogger('app:warn');
const infoLogger = createLogger('app:info');
/**
* Logs a server message with server icon prefix.
* @param logTitle - The main server message
* @param logData - Optional additional data to log
* @returns
*/
export const logServer = (logTitle:string, logData:string|null=null) => serverLogger(`⚡️ SERVER: ${logTitle}`, logData);
/**
* Logs a health check message with health check icon prefix.
* @param logTitle - The main health check message
* @param logData - Optional additional data to log
* @returns
*/
export const logHealthCheck = (logTitle:string, logData:string|null=null) => healthCheckLogger(`🩺 HEALTHCHECK: ${logTitle}`, logData);
/**
* Logs an error message with error icon prefix.
*
* @param logTitle - The main error message
* @param logData - Optional additional data to log
*/
export const logError = (logTitle:string, logData:string|null=null) => errorLogger(`❌ ERROR: ${logTitle}`, logData);
/**
* Logs a warning message with warning icon prefix.
*
* @param logTitle - The main warning message
* @param logData - Optional additional data to log
*/
export const logWarn = (logTitle:string, logData:string|null=null) => warnLogger(`⚠️ WARN: ${logTitle}`, logData);
/**
* Logs an informational message with info icon prefix.
*
* @param logTitle - The main info message
* @param logData - Optional additional data to log
*/
export const logInfo = (logTitle:string, logData:string|null=null) => infoLogger(`🛈 INFO: ${logTitle}`, logData);

View File

@@ -0,0 +1,60 @@
import { Counter, Histogram, register } from 'prom-client';
import { coalesce } from './initTools';
const { name:packageName } = require('../../package.json');
/** Histogram Buckets */
const PROMETHEUS_HISTOGRAM_BUCKETS = coalesce(process.env.PROMETHEUS_HISTOGRAM_BUCKETS, '0.1,0.5,1,5,10');
/** Label to identify metrics collected from this web service */
const PROMETHEUS_APP_LABEL = coalesce(process.env.PROMETHEUS_APP_LABEL, packageName);
// We use "app" labels to separate results in Grafana
register.setDefaultLabels({ app: PROMETHEUS_APP_LABEL });
/**
* Counts total number of requests received for processing
*/
export const totalRequestCounter = new Counter({
name: "request_operations_total",
help: "total number of received requests",
/** Separate counters by request type */
labelNames: ['path'],
});
/**
* Counts requests that were successfully processed
*/
export const successfulRequestCounter = new Counter({
name: "request_operations_ok",
help: "number of successfully processed requests",
/** Separate counters by request type */
labelNames: ['path'],
});
/**
* Counts requests that encountered an error during processing
*/
export const failedRequestCounter = new Counter({
name: "request_operations_failed",
help: "number of requests that encountered an error during processing",
/** Separate counters by request type and execution result */
labelNames: ["path", "status"],
});
/**
* Counts requests that were rejected (e.g. by validation)
*/
export const rejectedRequestCounter = new Counter({
name: "request_operations_rejected",
help: "number of requests that were rejected",
labelNames: ["path", "status"],
});
/** Histogram measures how long incoming request processing takes */
export const requestDurationHistogram = new Histogram({
name: "request_duration_seconds",
help: "Request duration in seconds",
/** Separate counters by request type and execution result */
labelNames: ["path", "status"],
buckets: PROMETHEUS_HISTOGRAM_BUCKETS?.split(',').map((el) => parseFloat(el))
});

View File

@@ -0,0 +1,43 @@
import { Response, Request, NextFunction } from 'express';
import { AppLocals } from '../types/AppLocals';
import { requestDurationHistogram, totalRequestCounter } from '../lib/metricsCounters';
import { SupportedRoutes } from '../types/enums/SupportedRoutes';
/**
* Middleware initializes infrastructure objects which will be used throughout the request lifecycle
* @param req - Express request object
* @param res - Express response object
* @param next - Express next middleware function
*/
export const InitLocalsMiddleware = (req: Request, res: Response, next: NextFunction) => {
try {
switch(req.path) {
// for metrics routes, no prometheus timer is needed
case SupportedRoutes.metricsPath:
case '/favicon.ico':
// placeholder method to avoid checking if timer is initialized
(res.locals as AppLocals).stopPrometheusTimer = (labels) => 0;
break;
// all other routes get prometheus metrics
default:
// The request must be processed even if Prometheus does not work
// That's why here we wrap the Prometheus calls in try/catch
try {
// counting all received requests
totalRequestCounter.inc({ path: req.path });
// starting a timer to measure request processing duration
// this timer will be stopped in the route handler
(res.locals as AppLocals).stopPrometheusTimer = requestDurationHistogram.startTimer();
} catch(ex:any) {
console.error(ex);
}
break;
}
next();
} catch(ex:any) {
console.error('Error in InitLocalsMiddleware:', ex);
next(ex);
}
}

View File

@@ -0,0 +1,97 @@
import { ErrorRequestHandler, Request, Response } from "express";
import createHttpError, { HttpError } from "http-errors";
import { logError, logWarn } from '../lib/logger';
import { AppLocals } from "../types/AppLocals";
import { failedRequestCounter, rejectedRequestCounter } from "../lib/metricsCounters";
import { SupportedRoutes } from "../types/enums/SupportedRoutes";
/**
* Error handler that processes and formats error responses.
* Handles different error types, logs appropriately, and updates metrics.
*
* @param err - HTTP error object
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const errorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const requestPath = req.path as SupportedRoutes;
// Since this error handler is complex, it might throw an error somewhere
// Wrap it in try-catch to ensure it won't crash the entire process
try {
let errorLogText:string = err.message,
errorLogName:string = err.name
const responseStatus:number = err.status;
let responseBody:string = "",
responseContentType = "text/html";
switch(err.status) {
case 400:
responseBody = 'bad request';
break;
case 401:
responseBody = 'unauthorized';
break;
case 403:
responseBody = 'forbidden';
break;
case 404:
logWarn(`page not found ${req.method} ${requestPath}`)
responseBody = 'page not found';
errorLogText = `page ${requestPath} not found`;
break;
case 500:
responseBody = "internal server error";
errorLogText = err.message;
break;
default:
responseBody = err.name;
errorLogText = `err.status=${err.status};err.name=${err.name};err.message=${err.message}`;
}
logWarn(`${errorLogName}:${errorLogText}`);
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
// If we try to set them again, it will throw an error - we can avoid that here
if(!res.headersSent) {
res.status(responseStatus);
res.setHeader('Content-Type', responseContentType);
res.end(responseBody);
} else {
// If `end` hasn't been called - call it to finish processing the request
// Otherwise the connection will remain open until timeout
if(!res.writableEnded) {
res.end();
}
}
} catch(ex:any) {
// This error will be handled by `finalErrorRouter`
next(createHttpError(500, ex));
}
// Prevent prometheus client from crashing the server
try {
switch(err.status) {
case 400:
case 401:
case 403:
case 404:
// Count rejected requests separately from errors
rejectedRequestCounter.inc({ path: requestPath, status: err.status });
break;
case 500:
default:
failedRequestCounter.inc({ path: requestPath, status: err.status });
break;
}
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path, status: err.status });
} catch(ex:any) {
logError(`Error while processing prometheus metrics: ${ex.message}`);
}
};

View File

@@ -0,0 +1,33 @@
import { ErrorRequestHandler, Request, Response } from "express";
import { HttpError } from "http-errors";
import { logError } from '../lib/logger';
/**
* Final error handler that executes when an unhandled error occurs.
* This prevents the server from crashing and ensures proper response handling.
*
* @param err - HTTP error object
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const finalErrorRouter:ErrorRequestHandler = async (err:HttpError, req, res, next) => {
const errorLogText:string = JSON.stringify({ message:err.message, name:err.name, stack:err.stack });
logError("server error", `${err.status}; n${errorLogText}`);
// `headersSent` will be TRUE if the router where the error occurred has already sent headers
// If we try to set them again, it will throw an error and CRASH THE SERVER - we must prevent this
if(!res.headersSent) {
res.status(err.status);
res.setHeader('Content-Type', "text/html");
res.end(`unhandled server error`);
} else {
// If `end` hasn't been called - call it to finish processing the request
// Otherwise the connection will remain open until timeout
if(!res.writableEnded) {
res.end();
}
}
};

View File

@@ -0,0 +1,19 @@
import { Router, NextFunction, Request, Response } from "express";
import createError from 'http-errors';
import { register } from 'prom-client';
import { logServer } from '../lib/logger';
/** Express router for Prometheus metrics endpoint */
export const metricsRouter = Router();
metricsRouter.get('/', async (req:Request, res:Response, next:NextFunction) => {
// Prevent Prometheus client from crashing the server
// This is not mission critical, so we can afford it not working but cannot allow it to crash the server
try {
logServer(`GET /metrics`);
res.set('Content-Type', register.contentType);
res.end(await register.metrics());
} catch(ex:any) {
next(createError(500, (ex as Error).message));
}
});

View File

@@ -0,0 +1,18 @@
import { RequestHandler, Router } from "express";
/**
* Request handler that responds to health check requests.
*
* @param req - Express request object
* @param res - Express response object
* @param next - Express next function
*/
export const pingRequestHandler:RequestHandler = async (req, res, next) => {
res.status(200);
res.setHeader('Content-Type', 'text/plain');
res.end('PONG');
};
/** Express router for ping/health check endpoint */
export const pingRouter = Router();
pingRouter.get('/', pingRequestHandler);

View File

@@ -0,0 +1,201 @@
/**
* MailGun Webhook Router
*
* Handles incoming webhook events from MailGun and logs them to the console.
* This router processes POST requests containing email event data such as
* delivered, failed, opened, clicked, bounced, complained, and unsubscribed events.
*/
import { Router, Request, Response, NextFunction } from 'express';
import createError from 'http-errors';
import { AppLocals } from '../types/AppLocals';
import { isMailgunWebhookPayload, isValidMailgunEvent, MailgunWebhookEvent, MailgunWebhookPayloadSignature } from '../types/MailgunWebhookEvent';
import { successfulRequestCounter } from '../lib/metricsCounters';
import { logInfo, logError, logWarn } from '../lib/logger';
import crypto from 'crypto';
const WEBHOOK_SIGNING_KEY = process.env.MAILGUN_WEBHOOK_SIGNING_KEY;
if (!WEBHOOK_SIGNING_KEY) {
logError('Configuration Error', 'MAILGUN_WEBHOOK_SIGNING_KEY environment variable is not set');
throw new Error('MAILGUN_WEBHOOK_SIGNING_KEY environment variable is required');
}
/**
* Verifies the MailGun webhook signature
* @param param0 - Object containing signingKey, timestamp, token, and signature
* @returns boolean indicating if the signature is valid
*/
const verifySignature = ({ timestamp, token, signature }: MailgunWebhookPayloadSignature) => {
const encodedToken = crypto
.createHmac('sha256', WEBHOOK_SIGNING_KEY)
.update(timestamp.concat(token))
.digest('hex')
return (encodedToken === signature)
}
/**
* Formats a Unix timestamp into a human-readable date string
* @param timestamp - Unix timestamp as string
* @returns Formatted date string in ISO format
*/
const formatTimestamp = (timestamp: string): string => {
try {
const timestampNum = parseInt(timestamp, 10);
if (isNaN(timestampNum)) {
return 'Invalid timestamp';
}
return new Date(timestampNum * 1000).toISOString();
} catch (error) {
return 'Invalid timestamp';
}
};
/**
* Logs MailGun webhook event data to console with structured formatting
* @param eventData - The MailGun webhook event data
*/
const logWebhookEvent = (eventData: MailgunWebhookEvent): void => {
const separator = '========================================';
const minorSeparator = '----------------------------------------';
console.log('\n' + separator);
console.log('MailGun Webhook Event Received');
console.log(separator);
console.log(`Event Type: ${eventData.event}`);
console.log(`Timestamp: ${eventData.timestamp} (${formatTimestamp(eventData.timestamp)})`);
console.log(`Recipient: ${eventData.recipient}`);
console.log(`Domain: ${eventData.domain}`);
if (eventData['message-id']) {
console.log(`MailGun Message ID: ${eventData['message-id']}`);
}
if (eventData['Message-Id']) {
console.log(`SMTP Message ID: ${eventData['Message-Id']}`);
}
// Log event-specific data
console.log(minorSeparator);
console.log('Event-Specific Data:');
switch (eventData.event) {
case 'delivered':
if (eventData['message-headers']) {
console.log(`Message Headers: ${eventData['message-headers'].substring(0, 200)}...`);
}
break;
case 'failed':
if (eventData.severity) console.log(`Severity: ${eventData.severity}`);
if (eventData.reason) console.log(`Reason: ${eventData.reason}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'opened':
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'clicked':
console.log(`URL Clicked: ${eventData.url}`);
if (eventData.city) console.log(`City: ${eventData.city}`);
if (eventData.country) console.log(`Country: ${eventData.country}`);
if (eventData['device-type']) console.log(`Device Type: ${eventData['device-type']}`);
if (eventData['client-os']) console.log(`Client OS: ${eventData['client-os']}`);
if (eventData['client-name']) console.log(`Client Name: ${eventData['client-name']}`);
if (eventData.ip) console.log(`IP Address: ${eventData.ip}`);
break;
case 'bounced':
if (eventData.code) console.log(`SMTP Code: ${eventData.code}`);
if (eventData.error) console.log(`Error: ${eventData.error}`);
if (eventData.notification) console.log(`Notification: ${eventData.notification}`);
break;
case 'complained':
console.log('User marked email as spam');
break;
case 'unsubscribed':
console.log('User unsubscribed from mailing list');
break;
}
// Log full event data for debugging
console.log(minorSeparator);
console.log('Full Event Data (JSON):');
console.log(JSON.stringify(eventData, null, 2));
console.log(separator + '\n');
};
/**
* Main webhook request handler
* Processes incoming MailGun webhook events and logs them to console
*/
export const webhookRequestHandler = async (req: Request, res: Response, next: NextFunction) => {
try {
const payload = req.body as any;
if(!isMailgunWebhookPayload(payload)) {
logWarn('Invalid webhook payload structure', JSON.stringify(payload));
next(createError(400, 'Invalid request format: payload structure is incorrect'));
return;
}
const { signature, "event-data": eventData } = payload;
// Validate that we have the minimum required fields
if (!isValidMailgunEvent(eventData)) {
logWarn('Invalid webhook event received', JSON.stringify(eventData));
next(createError(400, 'Invalid request format: missing required fields (event, recipient)'));
return;
}
// Verify webhook signature
const isValidSignature = verifySignature({
timestamp: signature.timestamp,
token: signature.token,
signature: signature.signature
});
if(!isValidSignature) {
logWarn('Invalid webhook signature', JSON.stringify(signature));
next(createError(401, 'Unauthorized: invalid webhook signature'));
return;
}
// Log the webhook event to console
logWebhookEvent(eventData as MailgunWebhookEvent);
// Log using the structured logger as well
logInfo('MailGun webhook event processed', `Event: ${eventData.event}, Recipient: ${eventData.recipient}`);
// Return success response to MailGun
res.status(200).json({
status: 'received',
message: 'Webhook event logged successfully'
});
// Increment successful requests counter for metrics
successfulRequestCounter.inc();
} catch (ex: any) {
logError('Error processing webhook', ex.message);
next(createError(500, 'Internal server error'));
}
// Stop Prometheus timer
(res.locals as AppLocals).stopPrometheusTimer({ path: req.path });
};
/**
* Express router for MailGun webhook endpoint
*/
const router = Router();
export const webhookRouter = router.post('/', webhookRequestHandler);

View File

@@ -0,0 +1,9 @@
import { LabelValues } from "prom-client";
export type PrometheusTimer = (labels?: LabelValues<"path"|"status">) => number;
/** Data assigned to `express.response.locals` */
export type AppLocals = {
/** Prometheus timer function for stopping request duration measurement */
stopPrometheusTimer: PrometheusTimer,
};

View File

@@ -0,0 +1,188 @@
/**
* Type definitions for MailGun webhook events
*/
/**
* All possible MailGun webhook event types
*/
export type MailgunEventType =
| 'delivered'
| 'failed'
| 'opened'
| 'clicked'
| 'bounced'
| 'complained'
| 'unsubscribed';
export interface MailgunWebhookPayload {
signature: MailgunWebhookPayloadSignature;
"event-data": MailgunWebhookEvent;
}
export interface MailgunWebhookPayloadSignature {
token: string;
timestamp: string;
signature: string;
}
/**
* Base interface for all MailGun webhook events
* Contains fields common to all event types
*/
export interface MailgunWebhookEventBase {
/** Type of event */
event: MailgunEventType;
/** Unix timestamp when the event occurred */
timestamp: string;
/** Randomly generated string for message signature verification */
token: string;
/** String with hexadecimal digits for signature verification */
signature: string;
/** Email address of the recipient */
recipient: string;
/** Domain from which the email was sent */
domain: string;
/** MailGun message ID */
'message-id'?: string;
/** SMTP Message-ID header */
'Message-Id'?: string;
}
/**
* Additional fields for 'delivered' events
*/
export interface MailgunDeliveredEvent extends MailgunWebhookEventBase {
event: 'delivered';
/** JSON string of message headers */
'message-headers'?: string;
}
/**
* Additional fields for 'failed' events
*/
export interface MailgunFailedEvent extends MailgunWebhookEventBase {
event: 'failed';
/** Severity level (temporary/permanent) */
severity?: string;
/** Reason for failure */
reason?: string;
/** Detailed notification message */
notification?: string;
}
/**
* Additional fields for events with tracking data (opened, clicked)
*/
export interface MailgunTrackingData {
/** City where email was opened */
city?: string;
/** Country code */
country?: string;
/** Device type (desktop/mobile/tablet) */
'device-type'?: string;
/** Operating system */
'client-os'?: string;
/** Email client name */
'client-name'?: string;
/** IP address */
ip?: string;
}
/**
* Additional fields for 'opened' events
*/
export interface MailgunOpenedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
event: 'opened';
}
/**
* Additional fields for 'clicked' events
*/
export interface MailgunClickedEvent extends MailgunWebhookEventBase, MailgunTrackingData {
event: 'clicked';
/** URL that was clicked */
url: string;
}
/**
* Additional fields for 'bounced' events
*/
export interface MailgunBouncedEvent extends MailgunWebhookEventBase {
event: 'bounced';
/** SMTP error code */
code?: string;
/** Detailed error message */
error?: string;
/** Bounce notification */
notification?: string;
}
/**
* Additional fields for 'complained' events
*/
export interface MailgunComplainedEvent extends MailgunWebhookEventBase {
event: 'complained';
}
/**
* Additional fields for 'unsubscribed' events
*/
export interface MailgunUnsubscribedEvent extends MailgunWebhookEventBase {
event: 'unsubscribed';
}
/**
* Union type of all possible MailGun webhook events
*/
export type MailgunWebhookEvent =
| MailgunDeliveredEvent
| MailgunFailedEvent
| MailgunOpenedEvent
| MailgunClickedEvent
| MailgunBouncedEvent
| MailgunComplainedEvent
| MailgunUnsubscribedEvent;
/**
* Type guard to check if event data has required fields
*/
export function isValidMailgunEvent(data: any): data is MailgunWebhookEventBase {
return (
data &&
typeof data === 'object' &&
typeof data.event === 'string' &&
typeof data.recipient === 'string'
);
}
/**
* Type guard to check if data is a MailgunWebhookPayload
*/
export function isMailgunWebhookPayload(data: any): data is MailgunWebhookPayload {
return (
data &&
typeof data === 'object' &&
typeof data.signature === 'object' &&
typeof data['event-data'] === 'object'
);
}

View File

@@ -0,0 +1,8 @@
export enum SupportedRoutes {
ping='/ping/',
metricsPath='/metrics',
/**
* MailGun webhook endpoint for receiving email event notifications
*/
webhook='/webhook',
}

View File

@@ -0,0 +1,20 @@
declare global {
namespace NodeJS {
interface ProcessEnv {
PORT?: string,
/**
* (optional) App label to be used in Prometheus (Grafana)
* @default "evo-open-table-sync-svc"
* */
PROMETHEUS_APP_LABEL?: string
/**
* (optional) Prometheus histogram bucket sizes (grafana)
* @default "0.1, 0.5, 1, 5, 10"
* */
PROMETHEUS_HISTOGRAM_BUCKETS?: string,
}
}
}
export {}

View File

@@ -0,0 +1 @@
export const randomUUID = () => 'mock-uuid-created-by-crypto';

View File

@@ -0,0 +1,18 @@
/**
* Mock implementation of the logger module for testing
*/
export const logServer = jest.fn();
export const logHealthCheck = jest.fn();
export const logError = jest.fn();
export const logWarn = jest.fn();
export const logInfo = jest.fn();
// Helper function to reset all mocks
export const resetAllLoggerMocks = () => {
logServer.mockClear();
logHealthCheck.mockClear();
logError.mockClear();
logWarn.mockClear();
logInfo.mockClear();
};

View File

@@ -0,0 +1,29 @@
import { LabelValues } from "prom-client";
export class Counter {
public inc() {
}
}
export class Histogram<T extends string> {
startTimer(labels?: LabelValues<T>): (labels?: LabelValues<T>) => void {
return((labels?: LabelValues<T>) => { });
}
}
class Register {
public setDefaultLabels(labels: Object) {
}
public metrics(): Promise<string> {
return(Promise.resolve(""));
}
public get contentType() {
return("");
}
}
export const register = new Register();

View File

@@ -0,0 +1,59 @@
/**
* @fileoverview
* This file is a part of an "auth" example router, which was taken from an existing integration.
* It is to be used only as a reference for how an API router for a web service should be structured.
* In a real-live implementation all files related to `/auth` route example should be removed
* by new set of files implementing the new API.
*/
import { Request, Response, NextFunction } from 'express';
import { AppLocals } from "../../src/types/AppLocals";
interface IMockHttpParams {
sessionID?: number
sessionGUID?: string
table_id?: string
clientIp?: string
free_play: 0 | 1
}
interface IMockHttpContext {
clientIpAddress?:string
reqPath?:string
headersSent?:boolean
writableEnded?:boolean
method?:string
params?: IMockHttpParams
}
export const defaultMockParams:IMockHttpParams = {
sessionID: 123,
sessionGUID: '016e6812-b915-4e5e-94fe-193582239b96',
table_id: 'mock-table-id',
clientIp: '192.168.1.10',
free_play: 0
}
export const mockHttpContext = ({reqPath="/", headersSent=false, writableEnded=false, method="GET", params=defaultMockParams}:IMockHttpContext|undefined = {}) => {
const req = {
path:reqPath,
method,
url:`https://localhost${reqPath}`,
params,
} as unknown as Request;
const res = {
end: jest.fn(),
status: jest.fn(),
setHeader: jest.fn(),
params,
locals: {
stopPrometheusTimer: jest.fn(),
} as unknown as AppLocals,
headersSent,
writableEnded,
} as unknown as Response;
const next:NextFunction = jest.fn();
return({req,res,next})
}

View File

@@ -0,0 +1,115 @@
/**
* Helper for creating mock MailGun webhook events for testing
*/
import {
MailgunWebhookEvent,
MailgunDeliveredEvent,
MailgunFailedEvent,
MailgunOpenedEvent,
MailgunClickedEvent,
MailgunBouncedEvent,
MailgunComplainedEvent,
MailgunUnsubscribedEvent
} from '../../src/types/MailgunWebhookEvent';
/**
* Base event data common to all webhook events
*/
const baseEventData = {
timestamp: '1234567890',
token: 'mock-token-123',
signature: 'mock-signature-abc123',
recipient: 'user@example.com',
domain: 'mail.example.com',
'message-id': '<20240101120000.1.ABC123@mail.example.com>',
'Message-Id': '<20240101120000.1.ABC123@mail.example.com>'
};
/**
* Creates a mock 'delivered' event
*/
export const createMockDeliveredEvent = (): MailgunDeliveredEvent => ({
...baseEventData,
event: 'delivered',
'message-headers': JSON.stringify([
['Subject', 'Test Email'],
['From', 'sender@example.com']
])
});
/**
* Creates a mock 'failed' event
*/
export const createMockFailedEvent = (): MailgunFailedEvent => ({
...baseEventData,
event: 'failed',
severity: 'permanent',
reason: 'bounce',
notification: 'User inbox is full'
});
/**
* Creates a mock 'opened' event
*/
export const createMockOpenedEvent = (): MailgunOpenedEvent => ({
...baseEventData,
event: 'opened',
city: 'San Francisco',
country: 'US',
'device-type': 'desktop',
'client-os': 'macOS',
'client-name': 'Apple Mail',
ip: '192.168.1.100'
});
/**
* Creates a mock 'clicked' event
*/
export const createMockClickedEvent = (): MailgunClickedEvent => ({
...baseEventData,
event: 'clicked',
url: 'https://example.com/link',
city: 'New York',
country: 'US',
'device-type': 'mobile',
'client-os': 'iOS',
'client-name': 'Gmail',
ip: '192.168.1.101'
});
/**
* Creates a mock 'bounced' event
*/
export const createMockBouncedEvent = (): MailgunBouncedEvent => ({
...baseEventData,
event: 'bounced',
code: '550',
error: 'User not found',
notification: 'The email account that you tried to reach does not exist'
});
/**
* Creates a mock 'complained' event
*/
export const createMockComplainedEvent = (): MailgunComplainedEvent => ({
...baseEventData,
event: 'complained'
});
/**
* Creates a mock 'unsubscribed' event
*/
export const createMockUnsubscribedEvent = (): MailgunUnsubscribedEvent => ({
...baseEventData,
event: 'unsubscribed'
});
/**
* Creates an invalid event missing required fields
*/
export const createInvalidEvent = () => ({
timestamp: '1234567890',
token: 'mock-token-123'
// Missing 'event' and 'recipient' fields
});

View File

@@ -0,0 +1,120 @@
import { errorRouter } from '../../src/routes/errorRouter';
import createError from "http-errors";
import { mockHttpContext } from "../helpers/mockHttpContext";
import { logWarn, resetAllLoggerMocks } from '../__mocks__/logger';
// Mock the logger module
jest.mock('../../src/lib/logger', () => require('../__mocks__/logger'));
describe("errorRouter", () => {
beforeEach(() => {
resetAllLoggerMocks();
});
test("should return string message 'page not found' in case of 404 error", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("page not found");
});
test("should log page not found warning in case of 404 error", async () => {
const err = createError(404)
const reqPath = "/some-path/";
const {req,res,next} = mockHttpContext({ reqPath });
await errorRouter(err, req, res, next);
expect(logWarn).toHaveBeenCalledWith(`page not found GET ${reqPath}`);
expect(logWarn).toHaveBeenCalledWith(`${err.name}:page ${reqPath} not found`);
});
test("should not send headers again if they are already sent", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:true });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).not.toHaveBeenCalled();
});
test("should call [end] method if it has NOT been called yet", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(res.setHeader).not.toHaveBeenCalled();
expect(res.end).toHaveBeenCalled();
});
test("should stop Prometheus Timer", async () => {
const err = createError(404)
const {req,res,next} = mockHttpContext({ headersSent:true, writableEnded:false });
await errorRouter(err, req, res, next);
expect(res.locals.stopPrometheusTimer).toHaveBeenCalled();
});
test("should return string message 'internal server error' in case of 500 error", async () => {
const err = createError(500)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(500);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("internal server error");
});
test("should return string message 'bad request' and log error in case of 400 error", async () => {
const errorMessage = "mock error text 1";
const err = createError(400, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("bad request");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
test("should return string message 'unauthorized' and log error in case of 401 error", async () => {
const errorMessage = "mock error text 2";
const err = createError(401, errorMessage)
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(401);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("unauthorized");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
test("should return string message 'forbidden' and log error in case of 403 error", async () => {
const errorMessage = "mock error text 3";
const err = createError(403, errorMessage);
const {req,res,next} = mockHttpContext();
await errorRouter(err, req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', "text/html");
expect(res.end).toHaveBeenCalledWith("forbidden");
expect(logWarn).toHaveBeenCalledWith(`${err.name}:${errorMessage}`);
});
});

View File

@@ -0,0 +1,196 @@
/**
* Tests for MailGun webhook router
*/
import request from 'supertest';
import app from '../../src/app';
import {
createMockDeliveredEvent,
createMockFailedEvent,
createMockOpenedEvent,
createMockClickedEvent,
createMockBouncedEvent,
createMockComplainedEvent,
createMockUnsubscribedEvent,
createInvalidEvent
} from '../helpers/mockWebhookEvent';
describe('MailGun Webhook Router', () => {
// Mock console.log to avoid cluttering test output
let consoleLogSpy: jest.SpyInstance;
beforeEach(() => {
consoleLogSpy = jest.spyOn(console, 'log').mockImplementation();
});
afterEach(() => {
consoleLogSpy.mockRestore();
});
describe('POST /webhook', () => {
it('should handle delivered event successfully', async () => {
const event = createMockDeliveredEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body).toEqual({
status: 'received',
message: 'Webhook event logged successfully'
});
// Verify console.log was called with event data
expect(consoleLogSpy).toHaveBeenCalled();
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('MailGun Webhook Event Received');
expect(logOutput).toContain('Event Type: delivered');
expect(logOutput).toContain('Recipient: user@example.com');
});
it('should handle failed event successfully', async () => {
const event = createMockFailedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body).toEqual({
status: 'received',
message: 'Webhook event logged successfully'
});
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: failed');
expect(logOutput).toContain('Severity: permanent');
expect(logOutput).toContain('Reason: bounce');
});
it('should handle opened event successfully', async () => {
const event = createMockOpenedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: opened');
expect(logOutput).toContain('City: San Francisco');
expect(logOutput).toContain('Device Type: desktop');
});
it('should handle clicked event successfully', async () => {
const event = createMockClickedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: clicked');
expect(logOutput).toContain('URL Clicked: https://example.com/link');
});
it('should handle bounced event successfully', async () => {
const event = createMockBouncedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: bounced');
expect(logOutput).toContain('SMTP Code: 550');
});
it('should handle complained event successfully', async () => {
const event = createMockComplainedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: complained');
});
it('should handle unsubscribed event successfully', async () => {
const event = createMockUnsubscribedEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Event Type: unsubscribed');
});
it('should return 400 for invalid event missing required fields', async () => {
const event = createInvalidEvent();
const response = await request(app)
.post('/webhook')
.send(event)
.expect(400);
// Error router returns plain text, not JSON
expect(response.text).toBe('bad request');
});
it('should handle URL-encoded form data (MailGun default format)', async () => {
const event = createMockDeliveredEvent();
const response = await request(app)
.post('/webhook')
.type('form')
.send(event)
.expect(200);
expect(response.body.status).toBe('received');
});
it('should log timestamp in human-readable format', async () => {
const event = createMockDeliveredEvent();
await request(app)
.post('/webhook')
.send(event)
.expect(200);
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Timestamp: 1234567890');
// Check that ISO format date is logged
expect(logOutput).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
});
it('should log full event data as JSON', async () => {
const event = createMockDeliveredEvent();
await request(app)
.post('/webhook')
.send(event)
.expect(200);
const logOutput = consoleLogSpy.mock.calls.join('\n');
expect(logOutput).toContain('Full Event Data (JSON)');
expect(logOutput).toContain('"event": "delivered"');
});
});
});

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "es2020", // https://github.com/microsoft/TypeScript/wiki/Node-Target-Mapping
"module": "commonjs",
"esModuleInterop": true, // solves the problem regarding the default importa vs importa *
"strict": true,
"sourceMap": true, // please do create source maps
"skipLibCheck": true, // don't verify typescript of 3rd party modules
"rootDir": "src", // root directory under which source files are located - it's subtree will be mirrored in "outDir"
"outDir": "build", // where the build files should be stored
// "baseUrl" ----- DO NOT USE ... heres why:
// NOTE: if "baseUrl" is set then Intellisense while doing autocompletion (Ctrl+Space)
// will use and insert absolute module path instead of relative one,
// which will make the build fail
// "baseUrl": "./", // set a base directory to resolve non-absolute module names - This must be specified if "paths" is used
"plugins": [
{
// The following is used for when building the project
// NOTE: build is done by `ttypescript`
// which does not know how to interpret what is set in "paths"
// > this problem is fixed by "typescript-transform-paths"
"transform": "typescript-transform-paths"
}
]
},
"include": ["src/**/*"], // location of files which need to be compiled
// The following is used for debugging the server in VS Code
// NOTE: when debugging the module is started using `ts-node`,
// which does not know how to interpret what is set in "paths"
// > this is fixed by "tsconfig-paths/register"
"ts-node": {
"require": ["tsconfig-paths/register"]
},
}

16888
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

26
package.json Normal file
View File

@@ -0,0 +1,26 @@
{
"name": "evidencija-rezija-monorepo",
"version": "1.0.0",
"description": "Property management and utility bills tracking monorepo",
"private": true,
"workspaces": [
"web-app",
"email-worker",
"shared-code",
"mailgun-webhook"
],
"scripts": {
"install:all": "npm install",
"build:web-app": "npm run build --workspace=web-app",
"build:email-worker": "npm run build --workspace=email-worker",
"dev:web-app": "npm run dev --workspace=web-app",
"dev:email-worker": "npm run start --workspace=email-worker"
},
"keywords": [
"property-management",
"utility-bills",
"monorepo"
],
"author": "",
"license": "ISC"
}

14
shared-code/package.json Normal file
View File

@@ -0,0 +1,14 @@
{
"name": "@evidencija-rezija/shared-code",
"version": "1.0.0",
"description": "Shared code for web-app and email-worker",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"mongodb": "^6.0.0"
}
}

View File

@@ -41,12 +41,45 @@ export enum EmailStatus {
Unverified = "unverified",
/** Email is not yet verified - a verification request has been sent */
VerificationPending = "verification-pending",
/** sending of verification email failed */
VerificationFailed = "verification-failed",
/** Email is verified and is in good standing: emails are being successfully delivered */
Verified = "verified",
/** Recepient has unsubscribed from receiving emails via link - no further emails will be sent */
Unsubscribed = "unsubscribed"
}
export enum BillsNotificationStrategy {
/** Notify tenant when bill is payed */
WhenPayed = "when-payed",
/** Notify tenant when bill gets an attachment */
WhenAttached = "when-attached"
}
export enum BillsNotificationStatus {
/** notification is scheduled to be sent */
Scheduled = "scheduled",
/** notification has been sent */
Sent = "sent",
/** notification has been successfully delivered (set by webhook) */
Delivered = "delivered",
/** Sending of notifications failed */
Failed = "failed",
/** Email to tenant has bounced (set by webhook) */
Bounced = "bounced"
}
export enum RentNotificationStatus {
/** notification has been sent */
Sent = "sent",
/** notification has been successfully delivered (set by webhook) */
Delivered = "delivered",
/** Sending of notification failed */
Failed = "failed",
/** Email to tenant has bounced (set by webhook) */
Bounced = "bounced"
}
/** bill object in the form returned by MongoDB */
export interface BillingLocation {
_id: string;
@@ -75,26 +108,32 @@ export interface BillingLocation {
tenantStreet?: string | null;
/** (optional) tenant town */
tenantTown?: string | null;
/** (optional) whether to automatically notify tenant */
autoBillFwd?: boolean | null;
/** (optional) tenant email */
tenantEmail?: string | null;
/** (optional) tenant email status */
tenantEmailStatus?: EmailStatus | null;
/** (optional) language for tenant notification emails */
tenantEmailLanguage?: "hr" | "en" | null;
/** (optional) whether to automatically notify tenant */
billsNotificationEnabled?: boolean | null;
/** (optional) bill forwarding strategy */
billFwdStrategy?: "when-payed" | "when-attached" | null;
billsNotificationStrategy?: BillsNotificationStrategy | null;
/** (optional) bill forwarding status */
billsNotificationStatus?: BillsNotificationStatus | null;
/** (optional) utility bills proof of payment attachment */
billsProofOfPayment?: FileAttachment|null;
/** (optional) whether to automatically send rent notification */
rentDueNotification?: boolean | null;
rentNotificationEnabled?: boolean | null;
/** (optional) when was the rent due notification sent */
rentNotificationStatus?: RentNotificationStatus | null;
/** (optional) rent proof of payment attachment */
rentProofOfPayment?: FileAttachment|null;
/** (optional) day of month when rent is due (1-31) */
rentDueDay?: number | null;
/** (optional) monthly rent amount in cents */
rentAmount?: number | null;
/** (optional) whether the location has been seen by tenant */
seenByTenantAt?: Date | null;
/** (optional) utility bills proof of payment attachment */
utilBillsProofOfPayment?: FileAttachment|null;
/** (optional) rent proof of payment attachment */
rentProofOfPayment?: FileAttachment|null;
/** (optional) share link expiry timestamp */
shareTTL?: Date;
/** (optional) when tenant first visited the share link */

5
shared-code/src/index.ts Normal file
View File

@@ -0,0 +1,5 @@
// Database types
export * from './db-types';
// Share checksum utilities
export * from './shareChecksum';

Some files were not shown because too many files have changed in this diff Show More