Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00473314f | ||
|
|
2dabcb2419 | ||
|
|
d9ae171cc7 | ||
|
|
e4e334c77a | ||
|
|
78d3d224af | ||
|
|
f9b1354460 | ||
|
|
95e5ec8485 | ||
|
|
d050b55baa | ||
|
|
7964563b62 | ||
|
|
e01dc4066c | ||
|
|
c286f49097 | ||
|
|
4c81d9ec04 | ||
|
|
a928666dfc | ||
|
|
d55dda3127 | ||
|
|
d637147f25 | ||
|
|
c1ed23c967 | ||
|
|
46001619e8 | ||
|
|
7a6df7760b | ||
|
|
d2d779c68f | ||
|
|
bbc12e0029 | ||
|
|
125718bb94 | ||
|
|
35d67c6b42 | ||
|
|
14c471e4ca | ||
|
|
42ceb6edc0 | ||
|
|
5bce1d74ff | ||
|
|
b4404f698e | ||
|
|
e4625894c9 | ||
|
|
af87d80baa | ||
|
|
1f84c9113c | ||
|
|
0cf0a26a97 | ||
|
|
0d4b694208 | ||
|
|
20ffb2b8bc | ||
|
|
0af79eb84b | ||
|
|
e2e98149cb | ||
|
|
f55ab31ae5 | ||
|
|
61a65d01c7 | ||
|
|
7aa3b74c6f | ||
|
|
7647109fb6 | ||
|
|
ad0c17da56 | ||
|
|
93ca6b8d94 | ||
|
|
c85df75d53 | ||
|
|
e16093a264 | ||
|
|
a54c8139b1 | ||
|
|
8de79e37e9 | ||
|
|
c662ecdbb2 | ||
|
|
8f6f63e572 | ||
|
|
22c22aba1b | ||
|
|
de106f9d91 | ||
|
|
8a38766c02 | ||
|
|
3a16ad3221 | ||
|
|
ec402cb9b3 | ||
|
|
663bc2be8d | ||
|
|
df28e2dc7d | ||
|
|
79fe9e94c7 | ||
|
|
c2afb2dfab | ||
|
|
0d1ed0ec67 | ||
|
|
99526ebfc2 | ||
|
|
e6baace00d | ||
|
|
743155b06a | ||
|
|
0a67a8278f | ||
|
|
26800a8553 | ||
|
|
93165644af | ||
|
|
4db1ccc3fc | ||
|
|
06c4d644b7 | ||
|
|
b5fa21a57c | ||
|
|
ddbe5237fe | ||
|
|
18e17cf198 | ||
|
|
6a6772d5ed | ||
|
|
50042cea27 | ||
|
|
3ccf890a21 | ||
|
|
d57f6c81cb | ||
|
|
c07816c2a1 | ||
|
|
73da0a5136 | ||
|
|
d12b797ebe | ||
|
|
7df43d8553 |
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
|
|||||||
# Default: true
|
# Default: true
|
||||||
# AUTO_PROJECT_DISABLE=false
|
# AUTO_PROJECT_DISABLE=false
|
||||||
|
|
||||||
|
# ========================================
|
||||||
|
# OPTIONAL: Self-Hosting User Management
|
||||||
|
# ========================================
|
||||||
|
# Controls whether new user signups are allowed
|
||||||
|
# When enabled (true), the signup endpoint will reject new user registration attempts
|
||||||
|
# Useful for private instances or when you want to manually manage users
|
||||||
|
# Default: false
|
||||||
|
# DISABLE_SIGNUPS=false
|
||||||
|
|
||||||
|
# Controls whether email validation checks are performed on signup
|
||||||
|
# When enabled (true), validates emails for disposable domains, plus-addressing, domain existence, and MX records
|
||||||
|
# When disabled (false), skips these validation checks and allows any email format
|
||||||
|
# Default: false
|
||||||
|
# VERIFY_EMAIL_ON_SIGNUP=false
|
||||||
|
|
||||||
# ========================================
|
# ========================================
|
||||||
# ADVANCED (rarely needed)
|
# ADVANCED (rarely needed)
|
||||||
# ========================================
|
# ========================================
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: Feature requests
|
||||||
|
about: Use this template to suggest new features for Plunk
|
||||||
|
title: ''
|
||||||
|
labels: enhancement
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Is your feature request related to a problem?
|
||||||
|
|
||||||
|
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||||
|
|
||||||
|
## Describe the solution you'd like
|
||||||
|
A clear and concise description of what you want to happen.
|
||||||
|
|
||||||
|
## Alternatives or workarounds
|
||||||
|
A clear and concise description of any alternative solutions or features you've considered.
|
||||||
|
|
||||||
|
## Additional context
|
||||||
|
Add any other context or screenshots about the feature request here.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
---
|
||||||
|
name: Product Issues
|
||||||
|
about: Use this template for bugs and issues on Plunk
|
||||||
|
title: ''
|
||||||
|
labels: bug
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
|
||||||
|
## To Reproduce
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
Please select the option that applies:
|
||||||
|
|
||||||
|
* Deployment type:
|
||||||
|
|
||||||
|
* [ ] Hosted
|
||||||
|
* [ ] Self-hosted
|
||||||
|
|
||||||
|
If self-hosted, please provide relevant details (Docker, Kubernetes, bare metal, etc.):
|
||||||
|
|
||||||
|
|
||||||
|
## Version verification
|
||||||
|
|
||||||
|
> ⚠️ **Important:**
|
||||||
|
> Self-hosted issues **must** be reproduced on the latest commit.
|
||||||
|
> Issues without a confirmed commit SHA may be closed without investigation.
|
||||||
|
|
||||||
|
* [ ] I am using the hosted version
|
||||||
|
|
||||||
|
**If self-hosted:**
|
||||||
|
|
||||||
|
* [ ] I have confirmed this issue still exists on the **latest commit** (not the `latest` tag)
|
||||||
|
|
||||||
|
* Commit SHA tested: `__________`
|
||||||
|
|
||||||
|
## Logs / Error output
|
||||||
|
|
||||||
|
If applicable, add logs, stack traces, or error messages here.
|
||||||
|
|
||||||
|
```
|
||||||
|
PASTE LOGS HERE
|
||||||
|
```
|
||||||
|
|
||||||
|
## Screenshots / Recordings / Additional context
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
Please confirm the following before submitting:
|
||||||
|
|
||||||
|
* [ ] I have searched existing issues to ensure this bug has not already been reported
|
||||||
|
* [ ] I have provided clear reproduction steps
|
||||||
|
* [ ] I have included all relevant environment details
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
".": "0.4.0"
|
".": "0.5.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ nodeLinker: node-modules
|
|||||||
|
|
||||||
# Support multiple architectures - download pre-built binaries instead of compiling
|
# Support multiple architectures - download pre-built binaries instead of compiling
|
||||||
supportedArchitectures:
|
supportedArchitectures:
|
||||||
os: [ "linux", "darwin" ]
|
os: [ "linux", "darwin", "win32" ]
|
||||||
cpu: [ "x64", "arm64" ]
|
cpu: [ "x64", "arm64" ]
|
||||||
libc: [ "glibc" ]
|
libc: [ "glibc" ]
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,55 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
|
||||||
|
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
|
||||||
|
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
|
||||||
|
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
|
||||||
|
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
|
||||||
|
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
|
||||||
|
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
|
||||||
|
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
|
||||||
|
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
|
||||||
|
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
|
||||||
|
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
|
||||||
|
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
|
||||||
|
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
|
||||||
|
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
|
||||||
|
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
|
||||||
|
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
|
||||||
|
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
|
||||||
|
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
|
||||||
|
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
|
||||||
|
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
|
||||||
|
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
|
||||||
|
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
|
||||||
|
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
|
||||||
|
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
|
||||||
|
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
|
||||||
|
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
|
||||||
|
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
|
||||||
|
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
|
||||||
|
|
||||||
|
|
||||||
|
### Code Refactoring
|
||||||
|
|
||||||
|
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
|
||||||
|
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
|
||||||
|
|
||||||
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
|
|||||||
### Applications (`apps/`)
|
### Applications (`apps/`)
|
||||||
|
|
||||||
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
|
||||||
- HTTP API endpoints for the platform
|
- HTTP API endpoints for the platform
|
||||||
- Background cron jobs (workflow processor, domain verification)
|
- Background cron jobs (workflow processor, domain verification)
|
||||||
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
|
||||||
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
|
||||||
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
- **landing**: Next.js app (Pages Router) - Marketing site (next.useplunk.com)
|
||||||
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
- **wiki**: Next.js app - Documentation site (next-wiki.useplunk.com)
|
||||||
@@ -148,6 +148,12 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
- Notifications (optional): `NTFY_URL` (ntfy.sh topic URL or self-hosted server for system notifications)
|
||||||
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
- Platform Email Notifications (optional): `PLUNK_API_KEY` (enables email notifications to users for critical events like
|
||||||
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
project disabled, billing limits, etc. If not set, only ntfy notifications are sent)
|
||||||
|
- Self-hosting User Management (optional):
|
||||||
|
- `DISABLE_SIGNUPS` (default: false) - When set to true, prevents new user signups via the API
|
||||||
|
- `VERIFY_EMAIL_ON_SIGNUP` (default: false) - When set to true, validates emails on signup for disposable domains,
|
||||||
|
plus-addressing, domain existence, and MX records
|
||||||
|
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
|
||||||
|
bounce/complaint rate thresholds are exceeded
|
||||||
|
|
||||||
**Important Notes:**
|
**Important Notes:**
|
||||||
|
|
||||||
@@ -159,6 +165,7 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
client-side access
|
client-side access
|
||||||
|
|
||||||
## Plugins
|
## Plugins
|
||||||
|
|
||||||
There are two plugins installed for you to use.
|
There are two plugins installed for you to use.
|
||||||
|
|
||||||
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
|
|||||||
USE_HTTPS=false
|
USE_HTTPS=false
|
||||||
|
|
||||||
API_URI=http://localhost:8080
|
API_URI=http://localhost:8080
|
||||||
|
WIKI_URI=http://localhost:1000
|
||||||
DASHBOARD_URI=http://localhost:3000
|
DASHBOARD_URI=http://localhost:3000
|
||||||
LANDING_URI=http://localhost:4000
|
LANDING_URI=http://localhost:4000
|
||||||
|
|
||||||
|
|||||||
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
|
|||||||
expect(result.data.subscribed).toBeUndefined();
|
expect(result.data.subscribed).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should create new contacts as unsubscribed when subscribed is undefined', async () => {
|
||||||
|
const newEmail = '[email protected]';
|
||||||
|
|
||||||
|
// Send email to new contact without specifying subscribed
|
||||||
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
|
const contact = await ContactService.upsert(projectId, newEmail, {name: 'Test'}, false);
|
||||||
|
|
||||||
|
// Transactional emails should create contacts as unsubscribed by default
|
||||||
|
expect(contact.subscribed).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('/v1/track endpoint', () => {
|
describe('/v1/track endpoint', () => {
|
||||||
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
|
|||||||
|
|
||||||
// Track event for new contact without specifying subscribed
|
// Track event for new contact without specifying subscribed
|
||||||
const {ContactService} = await import('../../services/ContactService.js');
|
const {ContactService} = await import('../../services/ContactService.js');
|
||||||
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, undefined);
|
const contact = await ContactService.upsert(projectId, newEmail, {event: 'test'}, true);
|
||||||
|
|
||||||
// New contacts should default to subscribed=true
|
// Event tracking should create contacts as subscribed by default
|
||||||
expect(contact.subscribed).toBe(true);
|
expect(contact.subscribed).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ export const PLUNK_ENABLED = PLUNK_API_KEY !== '' && PLUNK_FROM_ADDRESS !== '';
|
|||||||
// Useful for self-hosters who want to manage project status manually
|
// Useful for self-hosters who want to manage project status manually
|
||||||
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
export const AUTO_PROJECT_DISABLE = validateEnv('AUTO_PROJECT_DISABLE', 'true') === 'true';
|
||||||
|
|
||||||
|
// Self-hosting Configuration (optional)
|
||||||
|
// Controls whether new user signups are allowed (default: false)
|
||||||
|
export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
|
||||||
|
// Controls whether email validation checks are performed on signup (default: false)
|
||||||
|
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
|
||||||
|
|
||||||
// Email Verification & Password Reset
|
// Email Verification & Password Reset
|
||||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||||
|
|||||||
@@ -72,11 +72,12 @@ export class Actions {
|
|||||||
|
|
||||||
// Create or update contact with persistent data only
|
// Create or update contact with persistent data only
|
||||||
// ContactService.upsert will filter out non-persistent fields
|
// ContactService.upsert will filter out non-persistent fields
|
||||||
|
// Event tracking should subscribe contacts by default
|
||||||
const contact = await ContactService.upsert(
|
const contact = await ContactService.upsert(
|
||||||
auth.projectId,
|
auth.projectId,
|
||||||
email,
|
email,
|
||||||
data as Record<string, unknown> | undefined,
|
data as Record<string, unknown> | undefined,
|
||||||
subscribed,
|
subscribed ?? true,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Track the event with ALL data (persistent + non-persistent)
|
// Track the event with ALL data (persistent + non-persistent)
|
||||||
@@ -266,7 +267,8 @@ export class Actions {
|
|||||||
: (data as Record<string, unknown> | undefined);
|
: (data as Record<string, unknown> | undefined);
|
||||||
|
|
||||||
// Create or update contact with metadata
|
// Create or update contact with metadata
|
||||||
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed);
|
// Transactional emails should not subscribe contacts by default
|
||||||
|
const contact = await ContactService.upsert(auth.projectId, recipient.email, recipientData, subscribed ?? false);
|
||||||
|
|
||||||
// Get merged data including non-persistent fields for template rendering
|
// Get merged data including non-persistent fields for template rendering
|
||||||
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import * as React from 'react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
EMAIL_VERIFICATION_RATE_LIMIT,
|
EMAIL_VERIFICATION_RATE_LIMIT,
|
||||||
EMAIL_VERIFICATION_RATE_WINDOW,
|
EMAIL_VERIFICATION_RATE_WINDOW,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
@@ -15,6 +16,7 @@ import {
|
|||||||
PASSWORD_RESET_RATE_LIMIT,
|
PASSWORD_RESET_RATE_LIMIT,
|
||||||
PLUNK_ENABLED,
|
PLUNK_ENABLED,
|
||||||
TOKEN_EXPIRY_SECONDS,
|
TOKEN_EXPIRY_SECONDS,
|
||||||
|
VERIFY_EMAIL_ON_SIGNUP,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
|
||||||
@@ -63,33 +65,43 @@ export class Auth {
|
|||||||
@Post('signup')
|
@Post('signup')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async signup(req: Request, res: Response, _next: NextFunction) {
|
public async signup(req: Request, res: Response, _next: NextFunction) {
|
||||||
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
// Verify email is valid and not disposable/plus-addressed
|
|
||||||
const verification = await EmailVerificationService.verifyEmail(email);
|
|
||||||
|
|
||||||
if (
|
|
||||||
verification.isDisposable ||
|
|
||||||
verification.isPlusAddressed ||
|
|
||||||
!verification.domainExists ||
|
|
||||||
!verification.hasMxRecords
|
|
||||||
) {
|
|
||||||
// Build list of reasons for notification
|
|
||||||
const reasons: string[] = [];
|
|
||||||
if (verification.isDisposable) reasons.push('disposable email');
|
|
||||||
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
|
||||||
if (!verification.domainExists) reasons.push('domain does not exist');
|
|
||||||
if (!verification.hasMxRecords) reasons.push('no MX records');
|
|
||||||
|
|
||||||
// Send notification about failed signup attempt
|
|
||||||
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: false,
|
success: false,
|
||||||
data: 'This email address cannot be used for signup',
|
data: 'New user signups are currently disabled',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const {email, password} = AuthenticationSchemas.login.parse(req.body);
|
||||||
|
|
||||||
|
// Verify email is valid and not disposable/plus-addressed (if verification enabled)
|
||||||
|
if (VERIFY_EMAIL_ON_SIGNUP) {
|
||||||
|
const verification = await EmailVerificationService.verifyEmail(email);
|
||||||
|
|
||||||
|
if (
|
||||||
|
verification.isDisposable ||
|
||||||
|
verification.isPlusAddressed ||
|
||||||
|
!verification.domainExists ||
|
||||||
|
!verification.hasMxRecords
|
||||||
|
) {
|
||||||
|
// Build list of reasons for notification
|
||||||
|
const reasons: string[] = [];
|
||||||
|
if (verification.isDisposable) reasons.push('disposable email');
|
||||||
|
if (verification.isPlusAddressed) reasons.push('plus addressing');
|
||||||
|
if (!verification.domainExists) reasons.push('domain does not exist');
|
||||||
|
if (!verification.hasMxRecords) reasons.push('no MX records');
|
||||||
|
|
||||||
|
// Send notification about failed signup attempt
|
||||||
|
await NtfyService.notifyFailedSignupAttempt(email, reasons);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
success: false,
|
||||||
|
data: 'This email address cannot be used for signup',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await UserService.email(email);
|
const user = await UserService.email(email);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
|
AWS_SES_REGION,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
@@ -59,6 +60,9 @@ export class Config {
|
|||||||
: null,
|
: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
aws: {
|
||||||
|
sesRegion: AWS_SES_REGION,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GITHUB_OAUTH_CLIENT,
|
GITHUB_OAUTH_CLIENT,
|
||||||
GITHUB_OAUTH_ENABLED,
|
GITHUB_OAUTH_ENABLED,
|
||||||
GITHUB_OAUTH_SECRET,
|
GITHUB_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -81,6 +83,11 @@ export class Github {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
|
|||||||
import {
|
import {
|
||||||
API_URI,
|
API_URI,
|
||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
|
DISABLE_SIGNUPS,
|
||||||
GOOGLE_OAUTH_CLIENT,
|
GOOGLE_OAUTH_CLIENT,
|
||||||
GOOGLE_OAUTH_ENABLED,
|
GOOGLE_OAUTH_ENABLED,
|
||||||
GOOGLE_OAUTH_SECRET,
|
GOOGLE_OAUTH_SECRET,
|
||||||
} from '../../app/constants.js';
|
} from '../../app/constants.js';
|
||||||
import {prisma} from '../../database/prisma.js';
|
import {prisma} from '../../database/prisma.js';
|
||||||
|
import {BadRequest} from '../../exceptions/index.js';
|
||||||
import {jwt} from '../../middleware/auth.js';
|
import {jwt} from '../../middleware/auth.js';
|
||||||
import {NtfyService} from '../../services/NtfyService.js';
|
import {NtfyService} from '../../services/NtfyService.js';
|
||||||
import {UserService} from '../../services/UserService.js';
|
import {UserService} from '../../services/UserService.js';
|
||||||
@@ -71,6 +73,11 @@ export class Google {
|
|||||||
let isNewUser = false;
|
let isNewUser = false;
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
// Check if signups are disabled
|
||||||
|
if (DISABLE_SIGNUPS) {
|
||||||
|
throw new BadRequest('New user signups are currently disabled');
|
||||||
|
}
|
||||||
|
|
||||||
user = await prisma.user.create({
|
user = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import type Stripe from 'stripe';
|
|||||||
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
|
||||||
import {stripe} from '../app/stripe.js';
|
import {stripe} from '../app/stripe.js';
|
||||||
import {prisma} from '../database/prisma.js';
|
import {prisma} from '../database/prisma.js';
|
||||||
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
import {NtfyService} from '../services/NtfyService.js';
|
import {NtfyService} from '../services/NtfyService.js';
|
||||||
import {SecurityService} from '../services/SecurityService.js';
|
import {SecurityService} from '../services/SecurityService.js';
|
||||||
@@ -21,7 +22,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
|
|||||||
export class Webhooks {
|
export class Webhooks {
|
||||||
/**
|
/**
|
||||||
* Receive SNS webhook notifications from AWS SES
|
* Receive SNS webhook notifications from AWS SES
|
||||||
* Handles email events: delivery, open, click, bounce, complaint
|
* Handles outbound email events: delivery, open, click, bounce, complaint
|
||||||
|
* Handles inbound email notifications: received emails via SES receiving
|
||||||
*/
|
*/
|
||||||
@Post('sns')
|
@Post('sns')
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
@@ -67,6 +69,114 @@ export class Webhooks {
|
|||||||
|
|
||||||
// Parse the nested SES event from the Message field
|
// Parse the nested SES event from the Message field
|
||||||
const body = JSON.parse(req.body.Message);
|
const body = JSON.parse(req.body.Message);
|
||||||
|
|
||||||
|
// Check if this is an inbound email notification (SES Receiving)
|
||||||
|
if (body.notificationType === 'Received') {
|
||||||
|
signale.info('[WEBHOOK] Received inbound email notification from SES');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Extract recipient addresses from the inbound email
|
||||||
|
const recipients = body.receipt?.recipients || [];
|
||||||
|
|
||||||
|
if (recipients.length === 0) {
|
||||||
|
signale.warn('[WEBHOOK] No recipients found in inbound email');
|
||||||
|
return res.status(200).json({success: true, message: 'No recipients found'});
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each recipient, identify the domain and create events
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const recipientEmail = recipient as string;
|
||||||
|
const domain = recipientEmail.split('@')[1];
|
||||||
|
|
||||||
|
if (!domain) {
|
||||||
|
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find ALL projects that have this domain verified
|
||||||
|
// A domain can be shared across multiple projects if users are members of both
|
||||||
|
const domainRecords = await prisma.domain.findMany({
|
||||||
|
where: {
|
||||||
|
domain,
|
||||||
|
verified: true, // Only process emails for verified domains
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
project: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (domainRecords.length === 0) {
|
||||||
|
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
signale.info(
|
||||||
|
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Extract sender information (same for all projects)
|
||||||
|
const senderEmail = body.mail?.source;
|
||||||
|
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
|
||||||
|
|
||||||
|
// Process inbound email for each project that has this domain verified
|
||||||
|
for (const domainRecord of domainRecords) {
|
||||||
|
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
|
||||||
|
|
||||||
|
// Find or create a contact for the sender in this project
|
||||||
|
let contact;
|
||||||
|
if (senderEmail) {
|
||||||
|
contact = await ContactService.upsert(
|
||||||
|
domainRecord.projectId,
|
||||||
|
senderEmail,
|
||||||
|
undefined, // No additional data
|
||||||
|
true, // Subscribe by default for inbound email senders
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare event data with all inbound email details
|
||||||
|
const eventData = {
|
||||||
|
messageId: body.mail?.messageId,
|
||||||
|
from: senderEmail,
|
||||||
|
fromHeader: senderFromHeader,
|
||||||
|
to: recipientEmail,
|
||||||
|
subject: body.mail?.commonHeaders?.subject,
|
||||||
|
timestamp: body.mail?.timestamp,
|
||||||
|
recipients: body.receipt?.recipients,
|
||||||
|
hasContent: !!body.content,
|
||||||
|
// Security verdicts
|
||||||
|
spamVerdict: body.receipt?.spamVerdict?.status,
|
||||||
|
virusVerdict: body.receipt?.virusVerdict?.status,
|
||||||
|
spfVerdict: body.receipt?.spfVerdict?.status,
|
||||||
|
dkimVerdict: body.receipt?.dkimVerdict?.status,
|
||||||
|
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
|
||||||
|
// Processing metadata
|
||||||
|
processingTimeMillis: body.receipt?.processingTimeMillis,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the email.received event (this will trigger workflows)
|
||||||
|
await EventService.trackEvent(
|
||||||
|
domainRecord.projectId,
|
||||||
|
'email.received',
|
||||||
|
contact?.id,
|
||||||
|
undefined, // No emailId for inbound emails (they're not sent by us)
|
||||||
|
eventData,
|
||||||
|
);
|
||||||
|
|
||||||
|
signale.success(
|
||||||
|
`[WEBHOOK] Created email.received event for ${senderEmail} → ${recipientEmail} (project: ${domainRecord.project.name})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.status(200).json({success: true, message: 'Inbound email processed'});
|
||||||
|
} catch (inboundError) {
|
||||||
|
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
|
||||||
|
// Return 200 to acknowledge receipt even if processing failed
|
||||||
|
return res.status(200).json({success: true, message: 'Error processing inbound email'});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle outbound email event notifications (existing logic)
|
||||||
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
|
||||||
const messageId = body.mail?.messageId;
|
const messageId = body.mail?.messageId;
|
||||||
|
|
||||||
@@ -152,29 +262,62 @@ export class Webhooks {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'Bounce':
|
case 'Bounce': {
|
||||||
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
|
const bounceType = body.bounce?.bounceType;
|
||||||
updateData.status = EmailStatus.BOUNCED;
|
const isPermanentBounce = bounceType === 'Permanent';
|
||||||
updateData.bouncedAt = now;
|
const isTransientBounce = bounceType === 'Transient';
|
||||||
// Unsubscribe contact on bounce
|
|
||||||
await prisma.contact.update({
|
|
||||||
where: {id: email.contactId},
|
|
||||||
data: {subscribed: false},
|
|
||||||
});
|
|
||||||
eventData = {
|
|
||||||
...baseEventData,
|
|
||||||
bounceType: body.bounce?.bounceType,
|
|
||||||
bouncedAt: now.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Send notification about bounce
|
if (isPermanentBounce) {
|
||||||
await NtfyService.notifyEmailBounce(
|
// Hard bounce - counts toward bounce rate and unsubscribes contact
|
||||||
email.project.name,
|
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
|
||||||
email.projectId,
|
updateData.status = EmailStatus.BOUNCED;
|
||||||
email.contact.email,
|
updateData.bouncedAt = now;
|
||||||
body.bounce?.bounceType,
|
// Unsubscribe contact on permanent bounce
|
||||||
);
|
await prisma.contact.update({
|
||||||
|
where: {id: email.contactId},
|
||||||
|
data: {subscribed: false},
|
||||||
|
});
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
bouncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send notification about permanent bounce
|
||||||
|
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||||
|
} else if (isTransientBounce) {
|
||||||
|
// Soft bounce (e.g., out-of-office, mailbox full) - don't count toward bounce rate
|
||||||
|
signale.info(
|
||||||
|
`[WEBHOOK] Transient bounce received for ${email.contact.email} from ${email.project.name} (not counted toward bounce rate)`,
|
||||||
|
);
|
||||||
|
// Don't update email status or unsubscribe contact
|
||||||
|
// Just track the event for visibility
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
transientBounce: true,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Unknown bounce type - treat as permanent to be safe
|
||||||
|
signale.warn(
|
||||||
|
`[WEBHOOK] Unknown bounce type (${bounceType}) received for ${email.contact.email} from ${email.project.name} - treating as permanent`,
|
||||||
|
);
|
||||||
|
updateData.status = EmailStatus.BOUNCED;
|
||||||
|
updateData.bouncedAt = now;
|
||||||
|
await prisma.contact.update({
|
||||||
|
where: {id: email.contactId},
|
||||||
|
data: {subscribed: false},
|
||||||
|
});
|
||||||
|
eventData = {
|
||||||
|
...baseEventData,
|
||||||
|
bounceType,
|
||||||
|
bouncedAt: now.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await NtfyService.notifyEmailBounce(email.project.name, email.projectId, email.contact.email, bounceType);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'Complaint':
|
case 'Complaint':
|
||||||
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
|
||||||
@@ -208,8 +351,10 @@ export class Webhooks {
|
|||||||
// Track event (this will trigger workflows)
|
// Track event (this will trigger workflows)
|
||||||
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
|
||||||
|
|
||||||
// Check security limits for bounce and complaint events
|
// Check security limits only for permanent bounces and complaints
|
||||||
if (eventType === 'Bounce' || eventType === 'Complaint') {
|
// Transient bounces (soft bounces) don't count toward bounce rate
|
||||||
|
const isPermanentBounce = eventType === 'Bounce' && body.bounce?.bounceType === 'Permanent';
|
||||||
|
if (isPermanentBounce || eventType === 'Complaint') {
|
||||||
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
await SecurityService.checkAndEnforceSecurityLimits(email.projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -122,9 +122,13 @@ export async function checkDomainVerifications() {
|
|||||||
|
|
||||||
// Send email notification about domain verified
|
// Send email notification about domain verified
|
||||||
try {
|
try {
|
||||||
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const ttl = 604800; // 7 days
|
||||||
if (alreadySent !== '1') {
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -138,7 +142,6 @@ export async function checkDomainVerifications() {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
|
||||||
);
|
);
|
||||||
await redis.setex(cacheKey, 604800, '1'); // 7 days
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -156,12 +159,17 @@ export async function checkDomainVerifications() {
|
|||||||
|
|
||||||
// Send email notification about domain verification failed
|
// Send email notification about domain verification failed
|
||||||
try {
|
try {
|
||||||
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
if (alreadySent !== '1') {
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(dbDomain.projectId);
|
const members = await MembershipService.getMembers(dbDomain.projectId);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -175,9 +183,6 @@ export async function checkDomainVerifications() {
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)),
|
||||||
);
|
);
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -117,10 +117,25 @@ export async function createEmailWorker() {
|
|||||||
const fromName = email.fromName || email.project.name;
|
const fromName = email.fromName || email.project.name;
|
||||||
const fromEmail = email.from;
|
const fromEmail = email.from;
|
||||||
|
|
||||||
|
// Parse custom headers from JSON
|
||||||
|
const customHeaders =
|
||||||
|
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
|
||||||
|
? (email.headers as Record<string, string>)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Check for custom recipient override in headers
|
||||||
|
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||||
|
|
||||||
|
// Remove internal headers before sending
|
||||||
|
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||||
|
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||||
|
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||||
|
}
|
||||||
|
|
||||||
// Build recipient with name if available
|
// Build recipient with name if available
|
||||||
const recipient: {name?: string; email: string} | string = email.toName
|
const recipient: {name?: string; email: string} | string = email.toName
|
||||||
? {name: email.toName, email: email.contact.email}
|
? {name: email.toName, email: recipientEmail}
|
||||||
: email.contact.email;
|
: recipientEmail;
|
||||||
|
|
||||||
// Determine tracking based on project settings and email type
|
// Determine tracking based on project settings and email type
|
||||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||||
@@ -137,6 +152,7 @@ export async function createEmailWorker() {
|
|||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
|
headers: publicHeaders,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
|
|||||||
* - For segments without trackMembership: only update counts
|
* - For segments without trackMembership: only update counts
|
||||||
*/
|
*/
|
||||||
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
|
||||||
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
|
|
||||||
|
|
||||||
// Get all segments for this project, separating tracked vs non-tracked
|
// Get all segments for this project, separating tracked vs non-tracked
|
||||||
const segments = await prisma.segment.findMany({
|
const segments = await prisma.segment.findMany({
|
||||||
where: {projectId},
|
where: {projectId},
|
||||||
@@ -29,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
const trackedSegments = segments.filter(s => s.trackMembership);
|
const trackedSegments = segments.filter(s => s.trackMembership);
|
||||||
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
|
||||||
|
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Process tracked segments with full membership computation (creates events)
|
// Process tracked segments with full membership computation (creates events)
|
||||||
|
let updatedSegmentCount = 0;
|
||||||
|
let totalAdded = 0;
|
||||||
|
let totalRemoved = 0;
|
||||||
|
|
||||||
if (trackedSegments.length > 0) {
|
if (trackedSegments.length > 0) {
|
||||||
for (const segment of trackedSegments) {
|
for (const segment of trackedSegments) {
|
||||||
try {
|
try {
|
||||||
signale.info(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
|
|
||||||
);
|
|
||||||
const result = await SegmentService.computeMembership(projectId, segment.id);
|
const result = await SegmentService.computeMembership(projectId, segment.id);
|
||||||
signale.success(
|
|
||||||
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Notify about segment membership update only if there were actual changes
|
// Track segments with actual changes for bundled notification
|
||||||
if (projectName && (result.added > 0 || result.removed > 0)) {
|
if (result.added > 0 || result.removed > 0) {
|
||||||
await NtfyService.notifySegmentMembershipComputed(
|
updatedSegmentCount++;
|
||||||
segment.name,
|
totalAdded += result.added;
|
||||||
projectName,
|
totalRemoved += result.removed;
|
||||||
projectId,
|
|
||||||
result.total,
|
|
||||||
result.added,
|
|
||||||
result.removed,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
|
||||||
// Continue with other segments
|
// Continue with other segments
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send bundled notification if there were any changes
|
||||||
|
if (projectName && updatedSegmentCount > 0) {
|
||||||
|
await NtfyService.notifySegmentMembershipBundled(
|
||||||
|
projectName,
|
||||||
|
projectId,
|
||||||
|
updatedSegmentCount,
|
||||||
|
totalAdded,
|
||||||
|
totalRemoved,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process non-tracked segments with count-only update (lightweight)
|
// Process non-tracked segments with count-only update (lightweight)
|
||||||
if (nonTrackedSegments.length > 0) {
|
if (nonTrackedSegments.length > 0) {
|
||||||
try {
|
try {
|
||||||
await SegmentService.refreshAllMemberCounts(projectId);
|
await SegmentService.refreshAllMemberCounts(projectId);
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
|
||||||
}
|
}
|
||||||
@@ -80,14 +77,10 @@ async function processProjectSegments(projectId: string, projectName?: string):
|
|||||||
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
|
||||||
const {projectId} = job.data;
|
const {projectId} = job.data;
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Process specific project
|
// Process specific project
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
|
|
||||||
await processProjectSegments(projectId);
|
await processProjectSegments(projectId);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
|
|
||||||
} else {
|
} else {
|
||||||
// Process all active projects
|
// Process all active projects
|
||||||
const projects = await prisma.project.findMany({
|
const projects = await prisma.project.findMany({
|
||||||
@@ -95,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
select: {id: true, name: true},
|
select: {id: true, name: true},
|
||||||
});
|
});
|
||||||
|
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
|
signale.info(`[SEGMENT-COUNT-WORKER] Processing ${projects.length} active projects`);
|
||||||
|
|
||||||
// Process projects in batches to avoid overwhelming the database
|
// Process projects in batches to avoid overwhelming the database
|
||||||
const PROJECT_BATCH_SIZE = 10;
|
const PROJECT_BATCH_SIZE = 10;
|
||||||
@@ -105,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
batch.map(async project => {
|
batch.map(async project => {
|
||||||
try {
|
try {
|
||||||
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
|
|
||||||
await processProjectSegments(project.id, project.name);
|
await processProjectSegments(project.id, project.name);
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
|
||||||
// Don't throw - continue with other projects
|
// Don't throw - continue with other projects
|
||||||
@@ -120,8 +111,6 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
|
|||||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
|
||||||
@@ -148,10 +137,6 @@ export function createSegmentCountWorker(): Worker {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
worker.on('completed', job => {
|
|
||||||
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.on('failed', (job, error) => {
|
worker.on('failed', (job, error) => {
|
||||||
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -366,15 +366,38 @@ export class ActivityService {
|
|||||||
...(contactId ? {contactId} : {}),
|
...(contactId ? {contactId} : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build OR conditions to filter by the appropriate timestamp field for each activity type
|
||||||
|
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
|
||||||
|
const orConditions: Prisma.EmailWhereInput[] = [];
|
||||||
|
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
|
||||||
|
orConditions.push({sentAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
|
||||||
|
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
|
||||||
|
orConditions.push({openedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
|
||||||
|
orConditions.push({clickedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
|
||||||
|
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
|
||||||
|
orConditions.push({complainedAt: {not: null, ...dateFilter}});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no OR conditions, return empty (shouldn't happen but defensive)
|
||||||
|
if (orConditions.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const emails = await prisma.email.findMany({
|
const emails = await prisma.email.findMany({
|
||||||
where: {
|
where: {
|
||||||
...where,
|
...where,
|
||||||
createdAt: cursorTimestamp
|
OR: orConditions,
|
||||||
? {
|
|
||||||
...dateFilter,
|
|
||||||
lt: cursorTimestamp,
|
|
||||||
}
|
|
||||||
: dateFilter,
|
|
||||||
},
|
},
|
||||||
orderBy: {createdAt: 'desc'},
|
orderBy: {createdAt: 'desc'},
|
||||||
take: limit,
|
take: limit,
|
||||||
@@ -401,6 +424,27 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Helper function to check if timestamp is within date range
|
||||||
|
const isInDateRange = (timestamp: Date | null) => {
|
||||||
|
if (!timestamp) return false;
|
||||||
|
|
||||||
|
// Check against date filter
|
||||||
|
const time = timestamp.getTime();
|
||||||
|
if (dateFilter.gte) {
|
||||||
|
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
|
||||||
|
if (time < gteTime) return false;
|
||||||
|
}
|
||||||
|
if (dateFilter.lte) {
|
||||||
|
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
|
||||||
|
if (time > lteTime) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against cursor for pagination
|
||||||
|
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
// Convert each email into multiple activities based on its state
|
// Convert each email into multiple activities based on its state
|
||||||
for (const email of emails) {
|
for (const email of emails) {
|
||||||
const baseMetadata = {
|
const baseMetadata = {
|
||||||
@@ -415,7 +459,7 @@ export class ActivityService {
|
|||||||
workflowName: email.workflowExecution?.workflow?.name,
|
workflowName: email.workflowExecution?.workflow?.name,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT))) {
|
if (email.sentAt && (!types || types.includes(ActivityType.EMAIL_SENT)) && isInDateRange(email.sentAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_sent`,
|
id: `${email.id}_sent`,
|
||||||
type: ActivityType.EMAIL_SENT,
|
type: ActivityType.EMAIL_SENT,
|
||||||
@@ -426,7 +470,11 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
|
if (
|
||||||
|
email.deliveredAt &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
|
||||||
|
isInDateRange(email.deliveredAt)
|
||||||
|
) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_delivered`,
|
id: `${email.id}_delivered`,
|
||||||
type: ActivityType.EMAIL_DELIVERED,
|
type: ActivityType.EMAIL_DELIVERED,
|
||||||
@@ -437,7 +485,7 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
|
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_opened`,
|
id: `${email.id}_opened`,
|
||||||
type: ActivityType.EMAIL_OPENED,
|
type: ActivityType.EMAIL_OPENED,
|
||||||
@@ -452,7 +500,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email clicked
|
// Email clicked
|
||||||
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED))) {
|
if (email.clickedAt && (!types || types.includes(ActivityType.EMAIL_CLICKED)) && isInDateRange(email.clickedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_clicked`,
|
id: `${email.id}_clicked`,
|
||||||
type: ActivityType.EMAIL_CLICKED,
|
type: ActivityType.EMAIL_CLICKED,
|
||||||
@@ -467,7 +515,7 @@ export class ActivityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Email bounced
|
// Email bounced
|
||||||
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED))) {
|
if (email.bouncedAt && (!types || types.includes(ActivityType.EMAIL_BOUNCED)) && isInDateRange(email.bouncedAt)) {
|
||||||
activities.push({
|
activities.push({
|
||||||
id: `${email.id}_bounced`,
|
id: `${email.id}_bounced`,
|
||||||
type: ActivityType.EMAIL_BOUNCED,
|
type: ActivityType.EMAIL_BOUNCED,
|
||||||
@@ -480,6 +528,25 @@ export class ActivityService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Email complaint
|
||||||
|
if (
|
||||||
|
email.complainedAt &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
|
||||||
|
isInDateRange(email.complainedAt)
|
||||||
|
) {
|
||||||
|
activities.push({
|
||||||
|
id: `${email.id}_complaint`,
|
||||||
|
type: ActivityType.EMAIL_COMPLAINT,
|
||||||
|
timestamp: email.complainedAt,
|
||||||
|
contactEmail: email.contact.email,
|
||||||
|
contactId: email.contactId,
|
||||||
|
metadata: {
|
||||||
|
...baseMetadata,
|
||||||
|
error: email.error,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return activities;
|
return activities;
|
||||||
|
|||||||
@@ -580,8 +580,15 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
|
// Email was already sent this month
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -603,11 +610,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Warning', template)));
|
||||||
|
|
||||||
// Mark that we've sent the warning email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
|
||||||
}
|
}
|
||||||
@@ -630,8 +632,15 @@ export class BillingLimitService {
|
|||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
|
||||||
|
|
||||||
const alreadySent = await redis.get(cacheKey);
|
// Use SETNX (SET if Not eXists) to atomically check and set the flag
|
||||||
if (alreadySent === '1') {
|
// This prevents race conditions where multiple concurrent requests could all pass the check
|
||||||
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
|
// Email was already sent this month
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,11 +661,6 @@ export class BillingLimitService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Billing Limit Exceeded', template)));
|
||||||
|
|
||||||
// Mark that we've sent the limit email (expires at end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {HttpException} from '../exceptions/index.js';
|
|||||||
import {Keys} from './keys.js';
|
import {Keys} from './keys.js';
|
||||||
import {MembershipService} from './MembershipService.js';
|
import {MembershipService} from './MembershipService.js';
|
||||||
import {NtfyService} from './NtfyService.js';
|
import {NtfyService} from './NtfyService.js';
|
||||||
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
|
||||||
|
|
||||||
export class DomainService {
|
export class DomainService {
|
||||||
/**
|
/**
|
||||||
@@ -89,10 +89,13 @@ export class DomainService {
|
|||||||
|
|
||||||
// Send email notification about domain verified
|
// Send email notification about domain verified
|
||||||
try {
|
try {
|
||||||
// Check deduplication cache
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
const cacheKey = Keys.Domain.verifiedEmail(domainId);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const ttl = 604800; // 7 days
|
||||||
if (alreadySent !== '1') {
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -104,8 +107,6 @@ export class DomainService {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)));
|
||||||
// Set cache to prevent duplicate emails (7 days)
|
|
||||||
await redis.setex(cacheKey, 604800, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -131,13 +132,17 @@ export class DomainService {
|
|||||||
|
|
||||||
// Send email notification about domain verification failed
|
// Send email notification about domain verification failed
|
||||||
try {
|
try {
|
||||||
// Check deduplication cache (monthly)
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const year = now.getFullYear();
|
const year = now.getFullYear();
|
||||||
const month = String(now.getMonth() + 1).padStart(2, '0');
|
const month = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
|
||||||
const alreadySent = await redis.get(cacheKey);
|
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
||||||
if (alreadySent !== '1') {
|
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
||||||
|
|
||||||
|
// SETNX returns 1 if key was set (didn't exist), 0 if key already existed
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (wasSet) {
|
||||||
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
const members = await MembershipService.getMembers(updatedDomain.project.id);
|
||||||
const emails = members.map(m => m.email);
|
const emails = members.map(m => m.email);
|
||||||
if (emails.length > 0) {
|
if (emails.length > 0) {
|
||||||
@@ -149,10 +154,6 @@ export class DomainService {
|
|||||||
landingUrl: LANDING_URI,
|
landingUrl: LANDING_URI,
|
||||||
});
|
});
|
||||||
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
await Promise.all(emails.map(email => sendPlatformEmail(email, 'Domain Verification Failed', template)));
|
||||||
// Set cache to prevent duplicate emails (until end of month)
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const ttl = Math.floor((endOfMonth.getTime() - now.getTime()) / 1000);
|
|
||||||
await redis.setex(cacheKey, ttl, '1');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (emailError) {
|
} catch (emailError) {
|
||||||
@@ -248,6 +249,28 @@ export class DomainService {
|
|||||||
|
|
||||||
await prisma.domain.delete({where: {id: domainId}});
|
await prisma.domain.delete({where: {id: domainId}});
|
||||||
|
|
||||||
|
// Check if this domain is still attached to another project
|
||||||
|
const domainExistsElsewhere = await prisma.domain.findFirst({
|
||||||
|
where: {
|
||||||
|
domain: domainName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// If domain is not used by any other project, remove it from AWS SES
|
||||||
|
if (!domainExistsElsewhere) {
|
||||||
|
try {
|
||||||
|
await deleteIdentity(domainName);
|
||||||
|
signale.info(`[DOMAIN] Removed AWS SES identity for ${domainName} (no longer used by any project)`);
|
||||||
|
} catch (error) {
|
||||||
|
// Log error but don't fail the domain removal if AWS cleanup fails
|
||||||
|
signale.error(`[DOMAIN] Failed to remove AWS SES identity for ${domainName}:`, error);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signale.info(
|
||||||
|
`[DOMAIN] Keeping AWS SES identity for ${domainName} (still used by project ${domainExistsElsewhere.projectId})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification about domain removal
|
// Send notification about domain removal
|
||||||
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ interface SendEmailParams {
|
|||||||
campaignId?: string;
|
campaignId?: string;
|
||||||
workflowExecutionId?: string;
|
workflowExecutionId?: string;
|
||||||
workflowStepExecutionId?: string;
|
workflowStepExecutionId?: string;
|
||||||
|
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,7 +194,8 @@ export class EmailService {
|
|||||||
|
|
||||||
// Check subscription status for marketing emails
|
// Check subscription status for marketing emails
|
||||||
// Transactional emails should always be sent regardless of subscription status
|
// Transactional emails should always be sent regardless of subscription status
|
||||||
if (sourceType !== EmailSourceType.TRANSACTIONAL) {
|
// Custom recipient emails also bypass subscription checks (they're not in the contact list)
|
||||||
|
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
|
||||||
const contact = await prisma.contact.findUnique({
|
const contact = await prisma.contact.findUnique({
|
||||||
where: {id: params.contactId},
|
where: {id: params.contactId},
|
||||||
select: {subscribed: true},
|
select: {subscribed: true},
|
||||||
@@ -242,6 +244,12 @@ export class EmailService {
|
|||||||
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If custom recipient email is provided, store it in headers for later use
|
||||||
|
const emailHeaders = params.headers ? {...params.headers} : {};
|
||||||
|
if (params.recipientEmail) {
|
||||||
|
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
|
||||||
|
}
|
||||||
|
|
||||||
const email = await prisma.email.create({
|
const email = await prisma.email.create({
|
||||||
data: {
|
data: {
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
@@ -251,7 +259,7 @@ export class EmailService {
|
|||||||
from: params.from,
|
from: params.from,
|
||||||
fromName: params.fromName,
|
fromName: params.fromName,
|
||||||
replyTo: params.replyTo,
|
replyTo: params.replyTo,
|
||||||
headers: params.headers ? toPrismaJson(params.headers) : undefined,
|
headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
|
||||||
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
|
||||||
sourceType,
|
sourceType,
|
||||||
templateId: params.templateId,
|
templateId: params.templateId,
|
||||||
@@ -361,6 +369,15 @@ export class EmailService {
|
|||||||
? (email.headers as Record<string, string>)
|
? (email.headers as Record<string, string>)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Check for custom recipient override in headers
|
||||||
|
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
|
||||||
|
|
||||||
|
// Remove internal headers before sending
|
||||||
|
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
|
||||||
|
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
|
||||||
|
delete publicHeaders['X-Plunk-Recipient-Override'];
|
||||||
|
}
|
||||||
|
|
||||||
// Parse attachments from JSON
|
// Parse attachments from JSON
|
||||||
const attachments =
|
const attachments =
|
||||||
email.attachments && Array.isArray(email.attachments)
|
email.attachments && Array.isArray(email.attachments)
|
||||||
@@ -376,13 +393,13 @@ export class EmailService {
|
|||||||
name: fromName,
|
name: fromName,
|
||||||
email: fromEmail,
|
email: fromEmail,
|
||||||
},
|
},
|
||||||
to: [email.contact.email],
|
to: [recipientEmail],
|
||||||
content: {
|
content: {
|
||||||
subject: formattedEmail.subject,
|
subject: formattedEmail.subject,
|
||||||
html: compiledHtml,
|
html: compiledHtml,
|
||||||
},
|
},
|
||||||
reply: email.replyTo || undefined,
|
reply: email.replyTo || undefined,
|
||||||
headers: customHeaders,
|
headers: publicHeaders,
|
||||||
attachments: attachments,
|
attachments: attachments,
|
||||||
tracking: shouldTrack,
|
tracking: shouldTrack,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ import {redis} from '../database/redis.js';
|
|||||||
const DISPOSABLE_DOMAINS_URL =
|
const DISPOSABLE_DOMAINS_URL =
|
||||||
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
|
||||||
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains';
|
||||||
|
const PERSONAL_DOMAINS_URL =
|
||||||
|
'https://gist.githubusercontent.com/ammarshah/f5c2624d767f91a7cbdc4e54db8dd0bf/raw/660fd949eba09c0b86574d9d3aa0f2137161fc7c/all_email_provider_domains.txt';
|
||||||
|
const PERSONAL_DOMAINS_CACHE_KEY = 'email:personal_domains';
|
||||||
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily)
|
||||||
|
|
||||||
// Known email forwarding/alias services
|
// Known email forwarding/alias services
|
||||||
@@ -39,12 +42,15 @@ const FORWARDING_DOMAINS = new Set([
|
|||||||
|
|
||||||
export class EmailVerificationService {
|
export class EmailVerificationService {
|
||||||
private static disposableDomainsSet: Set<string> | null = null;
|
private static disposableDomainsSet: Set<string> | null = null;
|
||||||
|
private static personalDomainsSet: Set<string> | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify an email address
|
* Verify an email address
|
||||||
* - Checks if domain exists (DNS A/AAAA records)
|
* - Checks for NS records (proves domain exists in DNS)
|
||||||
* - Checks for MX records
|
* - Checks for MX records (required for receiving email)
|
||||||
|
* - Checks for A/AAAA records (informational - indicates if domain has a website)
|
||||||
* - Detects disposable email addresses
|
* - Detects disposable email addresses
|
||||||
|
* - Detects personal/free email providers (Gmail, Hotmail, etc.)
|
||||||
* - Detects forwarding/alias email addresses
|
* - Detects forwarding/alias email addresses
|
||||||
* - Suggests corrections for common typos
|
* - Suggests corrections for common typos
|
||||||
*/
|
*/
|
||||||
@@ -56,7 +62,9 @@ export class EmailVerificationService {
|
|||||||
isAlias: false,
|
isAlias: false,
|
||||||
isTypo: false,
|
isTypo: false,
|
||||||
isPlusAddressed: false,
|
isPlusAddressed: false,
|
||||||
|
isPersonalEmail: false,
|
||||||
domainExists: false,
|
domainExists: false,
|
||||||
|
hasWebsite: false,
|
||||||
hasMxRecords: false,
|
hasMxRecords: false,
|
||||||
reasons: [],
|
reasons: [],
|
||||||
};
|
};
|
||||||
@@ -74,6 +82,9 @@ export class EmailVerificationService {
|
|||||||
// Check if email is from a disposable domain using GitHub list
|
// Check if email is from a disposable domain using GitHub list
|
||||||
result.isDisposable = await this.isDisposableDomain(domain);
|
result.isDisposable = await this.isDisposableDomain(domain);
|
||||||
|
|
||||||
|
// Check if email is from a personal/free email provider
|
||||||
|
result.isPersonalEmail = await this.isPersonalEmailDomain(domain);
|
||||||
|
|
||||||
// Check if email is from a known forwarding/alias service
|
// Check if email is from a known forwarding/alias service
|
||||||
result.isAlias = this.isForwardingDomain(domain);
|
result.isAlias = this.isForwardingDomain(domain);
|
||||||
|
|
||||||
@@ -88,35 +99,46 @@ export class EmailVerificationService {
|
|||||||
result.isTypo = true;
|
result.isTypo = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if domain exists (has any DNS records)
|
// Step 1: Check NS records - proves the domain exists in DNS
|
||||||
|
try {
|
||||||
|
const nsRecords = await dns.resolveNs(domain);
|
||||||
|
result.domainExists = nsRecords && nsRecords.length > 0;
|
||||||
|
} catch {
|
||||||
|
result.domainExists = false;
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain does not exist (no nameservers found)');
|
||||||
|
// If domain doesn't exist, no point checking MX/A records
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Check MX records - required for receiving email
|
||||||
|
try {
|
||||||
|
const mxRecords = await dns.resolveMx(domain);
|
||||||
|
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
||||||
|
if (!result.hasMxRecords) {
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
result.hasMxRecords = false;
|
||||||
|
result.valid = false;
|
||||||
|
result.reasons.push('Domain cannot receive email (no MX records found)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Check if domain has A/AAAA records - informational only
|
||||||
|
// This indicates if the domain has a website/web server
|
||||||
|
// Doesn't affect email validity since email only requires MX records
|
||||||
try {
|
try {
|
||||||
await dns.resolve(domain, 'A');
|
await dns.resolve(domain, 'A');
|
||||||
result.domainExists = true;
|
result.hasWebsite = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Try AAAA records if A records fail
|
// Try AAAA records if A records fail
|
||||||
try {
|
try {
|
||||||
await dns.resolve(domain, 'AAAA');
|
await dns.resolve(domain, 'AAAA');
|
||||||
result.domainExists = true;
|
result.hasWebsite = true;
|
||||||
} catch {
|
} catch {
|
||||||
result.domainExists = false;
|
// Domain doesn't have A/AAAA records (no website), but this is OK for email
|
||||||
result.valid = false;
|
result.hasWebsite = false;
|
||||||
result.reasons.push('Domain does not exist');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check MX records (only if domain exists)
|
|
||||||
if (result.domainExists) {
|
|
||||||
try {
|
|
||||||
const mxRecords = await dns.resolveMx(domain);
|
|
||||||
result.hasMxRecords = mxRecords && mxRecords.length > 0;
|
|
||||||
if (!result.hasMxRecords) {
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
result.hasMxRecords = false;
|
|
||||||
result.valid = false;
|
|
||||||
result.reasons.push('No MX records found for domain');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,4 +209,57 @@ export class EmailVerificationService {
|
|||||||
private static isForwardingDomain(domain: string): boolean {
|
private static isForwardingDomain(domain: string): boolean {
|
||||||
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
return FORWARDING_DOMAINS.has(domain.toLowerCase());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache the personal email domains list from GitHub
|
||||||
|
* Uses Redis for caching with 24-hour TTL
|
||||||
|
* Falls back to in-memory cache if Redis fails
|
||||||
|
*/
|
||||||
|
private static async getPersonalEmailDomains(): Promise<Set<string>> {
|
||||||
|
// Return in-memory cache if available
|
||||||
|
if (this.personalDomainsSet) {
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try to get from Redis cache first
|
||||||
|
const cached = await redis.get(PERSONAL_DOMAINS_CACHE_KEY);
|
||||||
|
if (cached) {
|
||||||
|
const domains = JSON.parse(cached) as string[];
|
||||||
|
this.personalDomainsSet = new Set(domains);
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from GitHub if not in cache
|
||||||
|
const response = await fetch(PERSONAL_DOMAINS_URL);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to fetch personal email domains: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = await response.text();
|
||||||
|
const domains = text
|
||||||
|
.split('\n')
|
||||||
|
.map(line => line.trim())
|
||||||
|
.filter(line => line && !line.startsWith('#')); // Filter empty lines and comments
|
||||||
|
|
||||||
|
// Cache in Redis
|
||||||
|
await redis.set(PERSONAL_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS);
|
||||||
|
|
||||||
|
// Cache in memory
|
||||||
|
this.personalDomainsSet = new Set(domains);
|
||||||
|
return this.personalDomainsSet;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching personal email domains:', error);
|
||||||
|
// Return empty set as fallback - don't block email verification
|
||||||
|
return new Set<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a domain is a personal/free email provider
|
||||||
|
*/
|
||||||
|
private static async isPersonalEmailDomain(domain: string): Promise<boolean> {
|
||||||
|
const personalDomains = await this.getPersonalEmailDomains();
|
||||||
|
return personalDomains.has(domain.toLowerCase());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,9 +211,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:security:warning:${projectId}`;
|
const cacheKey = `ntfy:security:warning:${projectId}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 3600; // 1 hour
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,9 +225,6 @@ export class NtfyService {
|
|||||||
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
|
||||||
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
[NtfyTag.WARNING, NtfyTag.SHIELD],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 1 hour
|
|
||||||
await redis.setex(cacheKey, 3600, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -606,9 +605,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
const cacheKey = `ntfy:billing:warning:${projectId}:${sourceType}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 86400; // 24 hours
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -617,9 +618,6 @@ export class NtfyService {
|
|||||||
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
|
||||||
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 24 hours
|
|
||||||
await redis.setex(cacheKey, 86400, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Billing and usage limit notifications =====
|
// ===== Billing and usage limit notifications =====
|
||||||
@@ -638,9 +636,11 @@ export class NtfyService {
|
|||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
const cacheKey = `ntfy:billing:exceeded:${projectId}:${sourceType}`;
|
||||||
const exists = await redis.exists(cacheKey);
|
const ttl = 86400; // 24 hours
|
||||||
|
|
||||||
if (exists) {
|
// Use SETNX to atomically check and set the flag (prevents race conditions)
|
||||||
|
const wasSet = await redis.set(cacheKey, '1', 'EX', ttl, 'NX');
|
||||||
|
if (!wasSet) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -649,9 +649,6 @@ export class NtfyService {
|
|||||||
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
|
||||||
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttle for 24 hours
|
|
||||||
await redis.setex(cacheKey, 86400, '1');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -765,6 +762,32 @@ export class NtfyService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notify about bundled segment membership updates - LOW priority
|
||||||
|
* Used when multiple segments are updated in a single processing cycle
|
||||||
|
*/
|
||||||
|
public static async notifySegmentMembershipBundled(
|
||||||
|
projectName: string,
|
||||||
|
projectId: string,
|
||||||
|
segmentCount: number,
|
||||||
|
totalAdded: number,
|
||||||
|
totalRemoved: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const changes: string[] = [];
|
||||||
|
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
|
||||||
|
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
|
||||||
|
|
||||||
|
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
|
||||||
|
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
|
||||||
|
|
||||||
|
await this.send({
|
||||||
|
title: 'Segment Memberships Updated',
|
||||||
|
message,
|
||||||
|
priority: NtfyPriority.LOW,
|
||||||
|
tags: [NtfyTag.CHART],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about segment deleted - MIN priority
|
* Notify about segment deleted - MIN priority
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
DASHBOARD_URI,
|
DASHBOARD_URI,
|
||||||
SES_CONFIGURATION_SET,
|
SES_CONFIGURATION_SET,
|
||||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||||
TRACKING_TOGGLE_ENABLED
|
TRACKING_TOGGLE_ENABLED,
|
||||||
} from '../app/constants.js';
|
} from '../app/constants.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -258,6 +258,13 @@ export const disableFeedbackForwarding = async (domain: string): Promise<void> =
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a verified domain identity from AWS SES
|
||||||
|
*/
|
||||||
|
export const deleteIdentity = async (domain: string): Promise<void> => {
|
||||||
|
await ses.deleteIdentity({Identity: domain});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get AWS SES account sending quota and rate limit
|
* Get AWS SES account sending quota and rate limit
|
||||||
* @returns MaxSendRate (emails per second) or null if the call fails
|
* @returns MaxSendRate (emails per second) or null if the call fails
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ const SECURITY_THRESHOLDS = {
|
|||||||
BOUNCE_ALLTIME_CRITICAL: 8,
|
BOUNCE_ALLTIME_CRITICAL: 8,
|
||||||
|
|
||||||
// Complaint rate thresholds (spam reports)
|
// Complaint rate thresholds (spam reports)
|
||||||
COMPLAINT_7DAY_WARNING: 0.05,
|
COMPLAINT_7DAY_WARNING: 0.075,
|
||||||
COMPLAINT_7DAY_CRITICAL: 0.1,
|
COMPLAINT_7DAY_CRITICAL: 0.15,
|
||||||
COMPLAINT_ALLTIME_WARNING: 0.02,
|
COMPLAINT_ALLTIME_WARNING: 0.03,
|
||||||
COMPLAINT_ALLTIME_CRITICAL: 0.08,
|
COMPLAINT_ALLTIME_CRITICAL: 0.12,
|
||||||
|
|
||||||
|
// Minimum absolute counts (prevents small sample size false positives)
|
||||||
|
// Both percentage AND absolute count must be exceeded to trigger
|
||||||
|
MIN_BOUNCES_FOR_CRITICAL: 10,
|
||||||
|
MIN_BOUNCES_FOR_WARNING: 5,
|
||||||
|
MIN_COMPLAINTS_FOR_CRITICAL: 5,
|
||||||
|
MIN_COMPLAINTS_FOR_WARNING: 3,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
interface RateData {
|
interface RateData {
|
||||||
@@ -280,51 +287,77 @@ export class SecurityService {
|
|||||||
|
|
||||||
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
// Check 7-day bounce rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL) {
|
// Critical: requires BOTH rate AND absolute count thresholds
|
||||||
|
if (
|
||||||
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL &&
|
||||||
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%)`,
|
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING) {
|
} else if (
|
||||||
|
sevenDay.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING &&
|
||||||
|
sevenDay.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%)`,
|
`7-day bounce rate (${sevenDay.bounceRate.toFixed(2)}%, ${sevenDay.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
// Check 7-day complaint rate (only if 7-day volume is sufficient)
|
||||||
if (hasMinimumVolume7Day) {
|
if (hasMinimumVolume7Day) {
|
||||||
if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL) {
|
// Critical: requires BOTH rate AND absolute count thresholds
|
||||||
|
if (
|
||||||
|
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL &&
|
||||||
|
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%)`,
|
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING) {
|
} else if (
|
||||||
|
sevenDay.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING &&
|
||||||
|
sevenDay.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%)`,
|
`7-day complaint rate (${sevenDay.complaintRate.toFixed(3)}%, ${sevenDay.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_7DAY_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all-time rates (only if all-time volume is sufficient)
|
// Check all-time rates (only if all-time volume is sufficient)
|
||||||
if (hasMinimumVolumeAllTime) {
|
if (hasMinimumVolumeAllTime) {
|
||||||
// Check all-time bounce rate
|
// Check all-time bounce rate - requires BOTH rate AND absolute count
|
||||||
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
|
if (
|
||||||
|
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
|
||||||
|
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%)`,
|
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds critical threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING) {
|
} else if (
|
||||||
|
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING &&
|
||||||
|
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%)`,
|
`All-time bounce rate (${allTime.bounceRate.toFixed(2)}%, ${allTime.bounces} bounces) exceeds warning threshold (${SECURITY_THRESHOLDS.BOUNCE_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check all-time complaint rate
|
// Check all-time complaint rate - requires BOTH rate AND absolute count
|
||||||
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
|
if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
|
||||||
|
) {
|
||||||
violations.push(
|
violations.push(
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%)`,
|
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds critical threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL} minimum)`,
|
||||||
);
|
);
|
||||||
} else if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING) {
|
} else if (
|
||||||
|
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING &&
|
||||||
|
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING
|
||||||
|
) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%)`,
|
`All-time complaint rate (${allTime.complaintRate.toFixed(3)}%, ${allTime.complaints} complaints) exceeds warning threshold (${SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_WARNING}%, ${SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_WARNING} minimum)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -584,8 +584,10 @@ export class SegmentService {
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -604,7 +606,9 @@ export class SegmentService {
|
|||||||
'greaterThanOrEqual',
|
'greaterThanOrEqual',
|
||||||
'lessThanOrEqual',
|
'lessThanOrEqual',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
];
|
];
|
||||||
|
|
||||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||||
@@ -612,7 +616,7 @@ export class SegmentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Validate unit for time-based operators
|
// Validate unit for time-based operators
|
||||||
if (['within', 'triggeredWithin'].includes(filter.operator) && !filter.unit) {
|
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -729,6 +733,20 @@ export class SegmentService {
|
|||||||
// Use ISO string for lexicographic comparison in JSON
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
return {data: {path, gte: since.toISOString() as Prisma.InputJsonValue}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// Note: Requires JSON date fields in ISO 8601 format for proper comparison
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the "before" date (X time units ago from now)
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
// Use ISO string for lexicographic comparison in JSON
|
||||||
|
return {data: {path, lt: before.toISOString() as Prisma.InputJsonValue}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -822,6 +840,18 @@ export class SegmentService {
|
|||||||
|
|
||||||
return {[field]: {gte: since}};
|
return {[field]: {gte: since}};
|
||||||
}
|
}
|
||||||
|
case 'olderThan': {
|
||||||
|
// "olderThan X days/hours/minutes" means more than X time units ago
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "olderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {[field]: {lt: before}};
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
throw new HttpException(400, `Unsupported operator for date field: ${operator}`);
|
||||||
}
|
}
|
||||||
@@ -921,6 +951,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact triggered this event, but only more than X time ago (not recently)
|
||||||
|
// This means: has event AND all occurrences are before the cutoff
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have triggered the event at some point
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
some: {
|
||||||
|
name: eventName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
events: {
|
||||||
|
none: {
|
||||||
|
name: eventName,
|
||||||
|
createdAt: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never triggered this event
|
// Contact has never triggered this event
|
||||||
return {
|
return {
|
||||||
@@ -995,6 +1061,42 @@ export class SegmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'triggeredOlderThan': {
|
||||||
|
// Contact had this email activity, but only more than X time ago (not recently)
|
||||||
|
if (!unit) {
|
||||||
|
throw new HttpException(400, 'Unit is required for "triggeredOlderThan" operator');
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||||
|
const before = new Date(now.getTime() - milliseconds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
// Must have the email activity at some point
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
some: {
|
||||||
|
[field]: {
|
||||||
|
not: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// But NOT within the recent timeframe
|
||||||
|
{
|
||||||
|
emails: {
|
||||||
|
none: {
|
||||||
|
[field]: {
|
||||||
|
gte: before,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'notTriggered':
|
case 'notTriggered':
|
||||||
// Contact has never had this email activity
|
// Contact has never had this email activity
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -519,18 +519,25 @@ export class WorkflowExecutionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SEND_EMAIL step - Send an email to the contact
|
* SEND_EMAIL step - Send an email to the contact or a custom recipient
|
||||||
*/
|
*/
|
||||||
private static async executeSendEmail(
|
private static async executeSendEmail(
|
||||||
step: WorkflowStepWithTemplate,
|
step: WorkflowStepWithTemplate,
|
||||||
execution: WorkflowExecutionWithRelations,
|
execution: WorkflowExecutionWithRelations,
|
||||||
stepExecution: WorkflowStepExecution,
|
stepExecution: WorkflowStepExecution,
|
||||||
_config: StepConfig,
|
config: StepConfig,
|
||||||
): Promise<StepResult> {
|
): Promise<StepResult> {
|
||||||
if (!step.template) {
|
if (!step.template) {
|
||||||
throw new Error('No template configured for SEND_EMAIL step');
|
throw new Error('No template configured for SEND_EMAIL step');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse step config to get recipient configuration
|
||||||
|
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
|
||||||
|
const recipientConfig =
|
||||||
|
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
|
||||||
|
? (stepConfig.recipient as {type?: string; customEmail?: string})
|
||||||
|
: {type: 'CONTACT'};
|
||||||
|
|
||||||
// Get contact data for variable substitution
|
// Get contact data for variable substitution
|
||||||
const contact = execution.contact;
|
const contact = execution.contact;
|
||||||
const contactData =
|
const contactData =
|
||||||
@@ -558,10 +565,20 @@ export class WorkflowExecutionService {
|
|||||||
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
const renderedSubject = this.renderTemplate(step.template.subject, variables);
|
||||||
const renderedBody = this.renderTemplate(step.template.body, variables);
|
const renderedBody = this.renderTemplate(step.template.body, variables);
|
||||||
|
|
||||||
|
// Determine recipient email
|
||||||
|
let recipientEmail = contact.email;
|
||||||
|
let recipientContactId = contact.id;
|
||||||
|
|
||||||
|
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
|
||||||
|
recipientEmail = recipientConfig.customEmail;
|
||||||
|
// For custom recipients, we don't associate with a contact
|
||||||
|
recipientContactId = contact.id; // Keep original contact for tracking
|
||||||
|
}
|
||||||
|
|
||||||
// Send email via EmailService
|
// Send email via EmailService
|
||||||
const email = await EmailService.sendWorkflowEmail({
|
const email = await EmailService.sendWorkflowEmail({
|
||||||
projectId: execution.workflow.projectId,
|
projectId: execution.workflow.projectId,
|
||||||
contactId: contact.id,
|
contactId: recipientContactId,
|
||||||
workflowExecutionId: execution.id,
|
workflowExecutionId: execution.id,
|
||||||
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
|
||||||
templateId: step.template.id,
|
templateId: step.template.id,
|
||||||
@@ -570,11 +587,15 @@ export class WorkflowExecutionService {
|
|||||||
from: step.template.from,
|
from: step.template.from,
|
||||||
fromName: step.template.fromName || undefined,
|
fromName: step.template.fromName || undefined,
|
||||||
replyTo: step.template.replyTo || undefined,
|
replyTo: step.template.replyTo || undefined,
|
||||||
|
// Pass custom recipient email if specified
|
||||||
|
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
emailId: email.id,
|
emailId: email.id,
|
||||||
sentAt: email.createdAt,
|
sentAt: email.createdAt,
|
||||||
|
recipientType: recipientConfig.type,
|
||||||
|
recipientEmail,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
"@tiptap/starter-kit": "^3.11.0",
|
"@tiptap/starter-kit": "^3.11.0",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3",
|
"react-dom": "19.2.3",
|
||||||
|
|||||||
@@ -2,15 +2,16 @@ import {
|
|||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
|
Forward,
|
||||||
Info,
|
Info,
|
||||||
Mail,
|
Mail,
|
||||||
Server,
|
Server,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
User,
|
||||||
XCircle,
|
XCircle,
|
||||||
Forward,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
|
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||||
|
|
||||||
interface EmailVerificationResultProps {
|
interface EmailVerificationResultProps {
|
||||||
result: VerificationResult;
|
result: VerificationResult;
|
||||||
@@ -52,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
<Server className="h-5 w-5 text-neutral-600" />
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium text-neutral-900">Domain Exists</p>
|
<p className="font-medium text-neutral-900">Domain Exists</p>
|
||||||
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
<p className="text-sm text-neutral-600">Domain has nameservers (NS records)</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{result.domainExists ? (
|
{result.domainExists ? (
|
||||||
@@ -78,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Website/A Records */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Server className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Has Website</p>
|
||||||
|
<p className="text-sm text-neutral-600">DNS A/AAAA records found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.hasWebsite ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Info className="h-5 w-5 text-neutral-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Disposable Email */}
|
{/* Disposable Email */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -94,6 +111,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Personal Email */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="h-5 w-5 text-neutral-600" />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-neutral-900">Personal Email</p>
|
||||||
|
<p className="text-sm text-neutral-600">Free email provider (Gmail, Hotmail, etc.)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{result.isPersonalEmail ? (
|
||||||
|
<Info className="h-5 w-5 text-blue-600" />
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-neutral-500">No</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Alias/Forwarding Email */}
|
{/* Alias/Forwarding Email */}
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
<div className="flex items-center justify-between px-6 py-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
export interface EmailVerificationResult {
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isAlias: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
|
||||||
const response = await fetch('/api/verify-email', {
|
const response = await fetch('/api/verify-email', {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {CheckCircle, Mail, Search, Shield} from 'lucide-react';
|
import {CheckCircle, Mail, Search, Shield, User} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Educational content for the email verification tool
|
* Educational content for the email verification tool
|
||||||
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
|
|||||||
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
|
||||||
icon: Mail,
|
icon: Mail,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Personal Email Detection',
|
||||||
|
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
|
||||||
|
icon: User,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Plus Addressing',
|
title: 'Plus Addressing',
|
||||||
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
|
||||||
|
|||||||
@@ -1,24 +1,16 @@
|
|||||||
import type {NextApiRequest, NextApiResponse} from 'next';
|
import type {NextApiRequest, NextApiResponse} from 'next';
|
||||||
|
import type {EmailVerificationResult} from '@plunk/types';
|
||||||
import {UtilitySchemas} from '@plunk/shared';
|
import {UtilitySchemas} from '@plunk/shared';
|
||||||
import {API_URI} from '../../lib/constants';
|
import {API_URI} from '../../lib/constants';
|
||||||
|
|
||||||
interface VerifyEmailResponse {
|
|
||||||
email: string;
|
|
||||||
valid: boolean;
|
|
||||||
isDisposable: boolean;
|
|
||||||
isTypo: boolean;
|
|
||||||
isPlusAddressed: boolean;
|
|
||||||
domainExists: boolean;
|
|
||||||
hasMxRecords: boolean;
|
|
||||||
suggestedEmail?: string;
|
|
||||||
reasons: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ErrorResponse {
|
interface ErrorResponse {
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<VerifyEmailResponse | ErrorResponse>) {
|
export default async function handler(
|
||||||
|
req: NextApiRequest,
|
||||||
|
res: NextApiResponse<EmailVerificationResult | ErrorResponse>,
|
||||||
|
) {
|
||||||
// Only allow POST requests
|
// Only allow POST requests
|
||||||
if (req.method !== 'POST') {
|
if (req.method !== 'POST') {
|
||||||
return res.status(405).json({error: 'Method not allowed'});
|
return res.status(405).json({error: 'Method not allowed'});
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {DASHBOARD_URI} from '../../lib/constants';
|
|||||||
import React, {useState} from 'react';
|
import React, {useState} from 'react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {ArrowRight, CheckCircle, Loader2, Search} from 'lucide-react';
|
import {ArrowRight, CheckCircle, Loader2, Search} from 'lucide-react';
|
||||||
import {type EmailVerificationResult as VerificationResult, verifyEmail} from '../../lib/emailVerification';
|
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
|
||||||
|
import {verifyEmail} from '../../lib/emailVerification';
|
||||||
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
import {EmailVerificationResult} from '../../components/tools/EmailVerificationResult';
|
||||||
import {Button, Input} from '@plunk/ui';
|
import {Button, Input} from '@plunk/ui';
|
||||||
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
|
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"juice": "^11.0.3",
|
"juice": "^11.0.3",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-seo": "^6.6.0",
|
"next-seo": "^6.6.0",
|
||||||
"nuqs": "^2.7.3",
|
"nuqs": "^2.7.3",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
setIsLoadingMore(true);
|
setIsLoadingMore(true);
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setActivities([]);
|
// Don't clear activities immediately - we'll do a smart merge
|
||||||
|
// This preserves React component instances and their local state
|
||||||
setNextCursor(undefined);
|
setNextCursor(undefined);
|
||||||
setHasMore(true);
|
setHasMore(true);
|
||||||
}
|
}
|
||||||
@@ -58,11 +59,25 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
|
const result = await network.fetch<CursorPaginatedResponse<Activity>>('GET', `/activity?${params.toString()}`);
|
||||||
|
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
// Append to existing activities
|
// Append to existing activities (pagination)
|
||||||
setActivities(prev => [...prev, ...result.data]);
|
setActivities(prev => [...prev, ...result.data]);
|
||||||
} else {
|
} else {
|
||||||
// Replace activities
|
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||||
setActivities(result.data);
|
setActivities(prev => {
|
||||||
|
// If no previous activities, just use new data
|
||||||
|
if (prev.length === 0) {
|
||||||
|
return result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing activities by ID for fast lookup
|
||||||
|
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||||
|
|
||||||
|
// For each new activity, reuse existing object if ID matches (preserves React component instances)
|
||||||
|
// Otherwise use new object. This maintains correct ordering while preserving component state.
|
||||||
|
return result.data.map(newActivity =>
|
||||||
|
existingMap.get(newActivity.id) ?? newActivity
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setNextCursor(result.cursor);
|
setNextCursor(result.cursor);
|
||||||
@@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
|||||||
|
|
||||||
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
const result = await network.fetch<{activities: Activity[]}>('GET', `/activity/upcoming?${params.toString()}`);
|
||||||
|
|
||||||
setUpcomingActivities(result.activities);
|
// Smart merge: preserve existing activity objects by ID to maintain component state
|
||||||
|
setUpcomingActivities(prev => {
|
||||||
|
// If no previous activities, just use new data
|
||||||
|
if (prev.length === 0) {
|
||||||
|
return result.activities;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing activities by ID for fast lookup
|
||||||
|
const existingMap = new Map(prev.map(activity => [activity.id, activity]));
|
||||||
|
|
||||||
|
// For each new activity, reuse existing object if ID matches
|
||||||
|
return result.activities.map(newActivity =>
|
||||||
|
existingMap.get(newActivity.id) ?? newActivity
|
||||||
|
);
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error fetching upcoming activities:', err);
|
console.error('Error fetching upcoming activities:', err);
|
||||||
// Don't set error state for upcoming - just fail silently
|
// Don't set error state for upcoming - just fail silently
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Eye,
|
Eye,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
Send,
|
Send,
|
||||||
|
ShieldAlert,
|
||||||
Workflow,
|
Workflow,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -95,7 +96,14 @@ function getUpcomingTime(date: Date): string {
|
|||||||
* Check if an activity is an email activity
|
* Check if an activity is an email activity
|
||||||
*/
|
*/
|
||||||
function isEmailActivity(type: string): boolean {
|
function isEmailActivity(type: string): boolean {
|
||||||
return ['email.sent', 'email.delivered', 'email.opened', 'email.clicked', 'email.bounced'].includes(type);
|
return [
|
||||||
|
'email.sent',
|
||||||
|
'email.delivered',
|
||||||
|
'email.opened',
|
||||||
|
'email.clicked',
|
||||||
|
'email.bounced',
|
||||||
|
'email.complaint',
|
||||||
|
].includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
@@ -226,6 +234,23 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'email.complaint':
|
||||||
|
return {
|
||||||
|
icon: ShieldAlert,
|
||||||
|
color: 'text-red-600',
|
||||||
|
bgColor: 'bg-red-100',
|
||||||
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
|
||||||
|
description: metadata.campaignName
|
||||||
|
? `Campaign: ${String(metadata.campaignName)}`
|
||||||
|
: metadata.workflowName
|
||||||
|
? `Workflow: ${String(metadata.workflowName)}`
|
||||||
|
: 'Recipient marked as spam',
|
||||||
|
badge: {
|
||||||
|
label: 'Complaint',
|
||||||
|
variant: 'destructive',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case 'workflow.started':
|
case 'workflow.started':
|
||||||
return {
|
return {
|
||||||
icon: Workflow,
|
icon: Workflow,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useUser} from '../lib/hooks/useUser';
|
import {useUser} from '../lib/hooks/useUser';
|
||||||
|
import {WIKI_URI} from '../lib/constants';
|
||||||
import {network} from '../lib/network';
|
import {network} from '../lib/network';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
FileText,
|
FileText,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -246,6 +248,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
|||||||
|
|
||||||
{/* Settings & User Menu */}
|
{/* Settings & User Menu */}
|
||||||
<div className="border-t border-neutral-200 p-3 space-y-1">
|
<div className="border-t border-neutral-200 p-3 space-y-1">
|
||||||
|
<a
|
||||||
|
href={WIKI_URI}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-700 hover:bg-neutral-50 hover:text-neutral-900"
|
||||||
|
>
|
||||||
|
<BookOpen className="h-5 w-5" />
|
||||||
|
Documentation
|
||||||
|
</a>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/settings"
|
href="/settings"
|
||||||
onClick={() => setShowMobileMenu(false)}
|
onClick={() => setShowMobileMenu(false)}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form';
|
|||||||
import {zodResolver} from '@hookform/resolvers/zod';
|
import {zodResolver} from '@hookform/resolvers/zod';
|
||||||
import {DomainSchemas} from '@plunk/shared';
|
import {DomainSchemas} from '@plunk/shared';
|
||||||
import {
|
import {
|
||||||
Alert,
|
|
||||||
AlertDescription,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -22,7 +20,8 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@plunk/ui';
|
} from '@plunk/ui';
|
||||||
import {AnimatePresence, motion} from 'framer-motion';
|
import {AnimatePresence, motion} from 'framer-motion';
|
||||||
import {Check, CheckCircle2, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
||||||
|
import {useConfig} from '../lib/hooks/useConfig';
|
||||||
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
||||||
|
|
||||||
interface DomainsSettingsProps {
|
interface DomainsSettingsProps {
|
||||||
@@ -34,6 +33,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
const {addDomain} = useAddDomain();
|
const {addDomain} = useAddDomain();
|
||||||
const {checkVerification} = useCheckDomainVerification();
|
const {checkVerification} = useCheckDomainVerification();
|
||||||
const {removeDomain} = useRemoveDomain();
|
const {removeDomain} = useRemoveDomain();
|
||||||
|
const {data: config} = useConfig();
|
||||||
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
@@ -47,6 +47,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
|
const [cooldownSeconds, setCooldownSeconds] = useState<{[key: string]: number}>({});
|
||||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||||
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
|
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
|
||||||
|
const [expandedDomains, setExpandedDomains] = useState<{[key: string]: boolean}>({});
|
||||||
|
|
||||||
const form = useForm<{domain: string}>({
|
const form = useForm<{domain: string}>({
|
||||||
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
|
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
|
||||||
@@ -86,6 +87,28 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [lastVerificationCheck]);
|
}, [lastVerificationCheck]);
|
||||||
|
|
||||||
|
// Auto-expand unverified domains on initial load
|
||||||
|
useEffect(() => {
|
||||||
|
if (domains && domains.length > 0) {
|
||||||
|
const unverifiedDomains = domains
|
||||||
|
.filter(d => !d.verified)
|
||||||
|
.reduce(
|
||||||
|
(acc, d) => {
|
||||||
|
acc[d.id] = true;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as {[key: string]: boolean},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Object.keys(unverifiedDomains).length > 0) {
|
||||||
|
setExpandedDomains(prev => ({
|
||||||
|
...prev,
|
||||||
|
...unverifiedDomains,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [domains]);
|
||||||
|
|
||||||
const showMessage = (type: 'success' | 'error', message: string) => {
|
const showMessage = (type: 'success' | 'error', message: string) => {
|
||||||
if (type === 'success') {
|
if (type === 'success') {
|
||||||
setSuccessMessage(message);
|
setSuccessMessage(message);
|
||||||
@@ -113,6 +136,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
setSelectedDomain(newDomain.id);
|
setSelectedDomain(newDomain.id);
|
||||||
|
// Auto-expand newly added domain
|
||||||
|
setExpandedDomains(prev => ({
|
||||||
|
...prev,
|
||||||
|
[newDomain.id]: true,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutateDomains();
|
await mutateDomains();
|
||||||
@@ -322,20 +350,42 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
|
{Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
|
||||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
<div className="mt-3">
|
||||||
<Alert>
|
<button
|
||||||
<AlertDescription>
|
onClick={() =>
|
||||||
<div className="space-y-4">
|
setExpandedDomains(prev => ({
|
||||||
<div>
|
...prev,
|
||||||
<p className="font-medium text-sm mb-1">DNS Configuration Required</p>
|
[domain.id]: !prev[domain.id],
|
||||||
<p className="text-xs text-neutral-600">
|
}))
|
||||||
Add the following DNS records to verify your domain. DNS changes can take up to 48
|
}
|
||||||
hours to propagate.
|
className="flex items-center gap-1.5 text-xs text-neutral-600 hover:text-neutral-900 transition-colors"
|
||||||
|
>
|
||||||
|
<ChevronDown
|
||||||
|
className={`h-3.5 w-3.5 transition-transform ${
|
||||||
|
expandedDomains[domain.id] ? 'rotate-180' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>{status.verified ? 'View DNS Records' : 'DNS Configuration Required'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expandedDomains[domain.id] && (
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
{/* Required DKIM Records for Sending */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Required for Sending</h4>
|
||||||
|
<Badge variant="default" className="text-[10px] px-1.5 py-0">
|
||||||
|
REQUIRED
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{!status.verified && (
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Add these DKIM records to verify your domain and send emails. DNS changes can take up
|
||||||
|
to 48 hours to propagate.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{/* DNS Records Table */}
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-xs border-collapse">
|
<table className="w-full text-xs border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -401,120 +451,246 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* MX Record */}
|
|
||||||
<tr className="hover:bg-neutral-50/50">
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<code className="text-xs font-medium text-neutral-900">MX</code>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
plunk.{domain.domain}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
10 feedback-smtp.eu-north-1.amazonses.com
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
|
||||||
handleCopyToken('10 feedback-smtp.eu-north-1.amazonses.com', 1000)
|
|
||||||
}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === '10 feedback-smtp.eu-north-1.amazonses.com-1000' ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{/* TXT Record (SPF) */}
|
|
||||||
<tr className="hover:bg-neutral-50/50">
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<code className="text-xs font-medium text-neutral-900">TXT</code>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
plunk.{domain.domain}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="py-3 px-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
|
||||||
"v=spf1 include:amazonses.com ~all"
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)}
|
|
||||||
className="shrink-0 h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
|
||||||
<Check className="h-3 w-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
||||||
<div className="text-blue-600 mt-0.5">
|
|
||||||
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
|
||||||
<path
|
|
||||||
fillRule="evenodd"
|
|
||||||
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
|
||||||
clipRule="evenodd"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-blue-900">
|
|
||||||
Click the copy icon to copy record values. After adding all records to your DNS
|
|
||||||
provider, use the refresh button above to verify your domain.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
{/* Optional: Custom MAIL FROM Domain */}
|
||||||
|
{config?.aws?.sesRegion && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Custom MAIL FROM Domain</h4>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
OPTIONAL
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
|
||||||
|
handle bounces/complaints.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200">
|
||||||
|
{/* MX Record (Bounce/Complaint Handling) */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">MX</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
plunk.{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `plunk.${domain.domain}-3000` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
10 feedback-smtp.{config.aws.sesRegion}.amazonses.com
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken(
|
||||||
|
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com`,
|
||||||
|
1000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken ===
|
||||||
|
`10 feedback-smtp.${config.aws.sesRegion}.amazonses.com-1000` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* TXT Record (SPF) */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">TXT</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
plunk.{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `plunk.${domain.domain}-3001` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
"v=spf1 include:amazonses.com ~all"
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken('"v=spf1 include:amazonses.com ~all"', 1001)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === '"v=spf1 include:amazonses.com ~all"-1001' ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional: Inbound Email */}
|
||||||
|
{config?.aws?.sesRegion && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<h4 className="text-xs font-semibold text-neutral-900">Inbound Email</h4>
|
||||||
|
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
|
||||||
|
OPTIONAL
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600 mb-2">
|
||||||
|
Configure this MX record to receive emails at your domain.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-xs border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-neutral-200">
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Name
|
||||||
|
</th>
|
||||||
|
<th className="text-left py-2 px-3 font-medium text-neutral-700 bg-neutral-50">
|
||||||
|
Value
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-neutral-200">
|
||||||
|
{/* Inbound MX Record */}
|
||||||
|
<tr className="hover:bg-neutral-50/50">
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<code className="text-xs font-medium text-neutral-900">MX</code>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
{domain.domain}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyToken(domain.domain, 3002)}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken === `${domain.domain}-3002` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="py-3 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<code className="text-xs font-mono text-neutral-700 break-all flex-1">
|
||||||
|
10 inbound-smtp.{config.aws.sesRegion}.amazonaws.com
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
handleCopyToken(
|
||||||
|
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com`,
|
||||||
|
1002,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="shrink-0 h-6 w-6 p-0"
|
||||||
|
>
|
||||||
|
{copiedToken ===
|
||||||
|
`10 inbound-smtp.${config.aws.sesRegion}.amazonaws.com-1002` ? (
|
||||||
|
<Check className="h-3 w-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 rounded-lg border border-blue-200 mt-3">
|
||||||
|
<div className="text-blue-600 mt-0.5">
|
||||||
|
<svg className="h-4 w-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-blue-900">
|
||||||
|
Click the copy icon to copy record values. After adding all records to your DNS
|
||||||
|
provider, use the refresh button above to verify your domain.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -27,12 +27,14 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
|||||||
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
|
||||||
{value: 'exists', label: 'Exists'},
|
{value: 'exists', label: 'Exists'},
|
||||||
{value: 'notExists', label: 'Does not exist'},
|
{value: 'notExists', label: 'Does not exist'},
|
||||||
{value: 'within', label: 'Within (time)'},
|
{value: 'within', label: 'Younger than (time)'},
|
||||||
|
{value: 'olderThan', label: 'Older than (time)'},
|
||||||
];
|
];
|
||||||
|
|
||||||
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
const EVENT_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
|
||||||
{value: 'triggered', label: 'Ever occurred'},
|
{value: 'triggered', label: 'Ever occurred'},
|
||||||
{value: 'triggeredWithin', label: 'Occurred within'},
|
{value: 'triggeredWithin', label: 'Occurred within'},
|
||||||
|
{value: 'triggeredOlderThan', label: 'Occurred over (time) ago'},
|
||||||
{value: 'notTriggered', label: 'Never occurred'},
|
{value: 'notTriggered', label: 'Never occurred'},
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -172,6 +174,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -183,7 +186,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
|
const needsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(filter.operator);
|
||||||
const needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
|
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator);
|
||||||
|
|
||||||
// Get field type from available fields
|
// Get field type from available fields
|
||||||
const fieldOption = useMemo(
|
const fieldOption = useMemo(
|
||||||
@@ -217,6 +220,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
].includes(op.value),
|
].includes(op.value),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -232,7 +236,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
const selectedField = availableFields.find(f => f.value === value);
|
const selectedField = availableFields.find(f => f.value === value);
|
||||||
const newFieldType = selectedField?.type || 'string';
|
const newFieldType = selectedField?.type || 'string';
|
||||||
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
const isEvent = newFieldType === 'event' || newFieldType === 'email';
|
||||||
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'notTriggered'].includes(filter.operator);
|
const currentOperatorIsEvent = ['triggered', 'triggeredWithin', 'triggeredOlderThan', 'notTriggered'].includes(
|
||||||
|
filter.operator,
|
||||||
|
);
|
||||||
|
|
||||||
// Determine default operator and value based on new field type
|
// Determine default operator and value based on new field type
|
||||||
let newOperator = filter.operator;
|
let newOperator = filter.operator;
|
||||||
@@ -267,7 +273,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
newUnit = undefined;
|
newUnit = undefined;
|
||||||
|
|
||||||
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
// If the new operator doesn't support units but we had them, ensure value is appropriate
|
||||||
const newOperatorNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
const newOperatorNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
|
newOperator,
|
||||||
|
);
|
||||||
if (!newOperatorNeedsUnit) {
|
if (!newOperatorNeedsUnit) {
|
||||||
// Convert numeric value back to appropriate type for the field
|
// Convert numeric value back to appropriate type for the field
|
||||||
newValue = getDefaultValueForType(newFieldType);
|
newValue = getDefaultValueForType(newFieldType);
|
||||||
@@ -399,8 +407,12 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
|
|||||||
// Check if we're switching between operators that need different value types
|
// Check if we're switching between operators that need different value types
|
||||||
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
|
||||||
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
|
||||||
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
|
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
|
oldOperator,
|
||||||
|
);
|
||||||
|
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
|
||||||
|
newOperator,
|
||||||
|
);
|
||||||
|
|
||||||
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
|
||||||
|
|
||||||
|
|||||||
@@ -128,7 +128,29 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return {nodes: layoutedNodes, edges};
|
// Center "Add Step" nodes directly below their parent nodes
|
||||||
|
const adjustedNodes = layoutedNodes.map(node => {
|
||||||
|
if (node.type === 'addStep') {
|
||||||
|
// Find the parent node (the source of the edge connecting to this add node)
|
||||||
|
const parentEdge = edges.find(edge => edge.target === node.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
const parentNode = layoutedNodes.find(n => n.id === parentEdge.source);
|
||||||
|
if (parentNode) {
|
||||||
|
// Center the add node below the parent node
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
position: {
|
||||||
|
x: parentNode.position.x + nodeWidth / 2 - 32, // 32 is half of the 64px (w-16) width of the add button
|
||||||
|
y: node.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {nodes: adjustedNodes, edges};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add Step Node - appears at the end of flow paths
|
// Add Step Node - appears at the end of flow paths
|
||||||
@@ -412,6 +434,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-yes`,
|
id: `${step.id}-add-yes`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: 'Yes',
|
label: 'Yes',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
|
||||||
@@ -424,6 +447,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-no`,
|
id: `${step.id}-add-no`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: 'No',
|
label: 'No',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
|
||||||
@@ -437,6 +461,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add`,
|
id: `${step.id}-add`,
|
||||||
type: 'addStep',
|
type: 'addStep',
|
||||||
position: {x: 0, y: 0},
|
position: {x: 0, y: 0},
|
||||||
|
draggable: false,
|
||||||
data: {
|
data: {
|
||||||
label: '',
|
label: '',
|
||||||
onClick: () => setAddStepContext({fromStepId: step.id}),
|
onClick: () => setAddStepContext({fromStepId: step.id}),
|
||||||
@@ -510,7 +535,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-yes-edge`,
|
id: `${step.id}-add-yes-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add-yes`,
|
target: `${step.id}-add-yes`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
label: 'Yes',
|
label: 'Yes',
|
||||||
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
|
||||||
@@ -527,7 +552,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-no-edge`,
|
id: `${step.id}-add-no-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add-no`,
|
target: `${step.id}-add-no`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
label: 'No',
|
label: 'No',
|
||||||
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
|
||||||
@@ -544,7 +569,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
id: `${step.id}-add-edge`,
|
id: `${step.id}-add-edge`,
|
||||||
source: step.id,
|
source: step.id,
|
||||||
target: `${step.id}-add`,
|
target: `${step.id}-add`,
|
||||||
type: 'smoothstep',
|
type: 'straight',
|
||||||
animated: false,
|
animated: false,
|
||||||
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
|
||||||
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
|
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
|
||||||
@@ -565,6 +590,56 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
|
||||||
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges);
|
||||||
|
|
||||||
|
// Custom nodes change handler that updates "+" node positions when parent nodes move
|
||||||
|
const handleNodesChange = useCallback(
|
||||||
|
(changes: any[]) => {
|
||||||
|
onNodesChange(changes);
|
||||||
|
|
||||||
|
// Check if any position changes occurred
|
||||||
|
const positionChanges = changes.filter(change => change.type === 'position' && change.dragging);
|
||||||
|
if (positionChanges.length > 0) {
|
||||||
|
setNodes(currentNodes => {
|
||||||
|
const updatedNodes = [...currentNodes];
|
||||||
|
const nodeWidth = 280;
|
||||||
|
|
||||||
|
positionChanges.forEach((change: any) => {
|
||||||
|
const movedNode = updatedNodes.find(n => n.id === change.id);
|
||||||
|
if (movedNode && movedNode.type === 'custom' && change.position) {
|
||||||
|
// Find all "+" nodes connected to this parent node
|
||||||
|
const connectedAddNodes = updatedNodes.filter(node => {
|
||||||
|
if (node.type !== 'addStep') return false;
|
||||||
|
// Check if this add node is connected to the moved node
|
||||||
|
return (
|
||||||
|
node.id === `${movedNode.id}-add` ||
|
||||||
|
node.id === `${movedNode.id}-add-yes` ||
|
||||||
|
node.id === `${movedNode.id}-add-no`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update positions of connected "+" nodes to stay centered below parent
|
||||||
|
connectedAddNodes.forEach(addNode => {
|
||||||
|
const addNodeIndex = updatedNodes.findIndex(n => n.id === addNode.id);
|
||||||
|
if (addNodeIndex !== -1 && updatedNodes[addNodeIndex]) {
|
||||||
|
const existingNode = updatedNodes[addNodeIndex];
|
||||||
|
updatedNodes[addNodeIndex] = {
|
||||||
|
...existingNode,
|
||||||
|
position: {
|
||||||
|
x: change.position.x + nodeWidth / 2 - 32, // Center below parent
|
||||||
|
y: existingNode.position.y,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedNodes;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onNodesChange, setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
// Update nodes/edges when layout changes
|
// Update nodes/edges when layout changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNodes(layoutedNodes);
|
setNodes(layoutedNodes);
|
||||||
@@ -727,7 +802,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={handleNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ export interface ConfigResponse {
|
|||||||
ports: {secure: number; submission: number} | null;
|
ports: {secure: number; submission: number} | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
aws: {
|
||||||
|
sesRegion: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+14
-11
@@ -5,6 +5,7 @@ import React, {useEffect} from 'react';
|
|||||||
import {Toaster} from 'sonner';
|
import {Toaster} from 'sonner';
|
||||||
import {SWRConfig} from 'swr';
|
import {SWRConfig} from 'swr';
|
||||||
import {DefaultSeo} from 'next-seo';
|
import {DefaultSeo} from 'next-seo';
|
||||||
|
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
|
||||||
import {Loader} from '@plunk/ui';
|
import {Loader} from '@plunk/ui';
|
||||||
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
|
||||||
import {useProjects} from '../lib/hooks/useProject';
|
import {useProjects} from '../lib/hooks/useProject';
|
||||||
@@ -103,17 +104,19 @@ function ProjectGuard({children}: {children: React.ReactNode}) {
|
|||||||
*/
|
*/
|
||||||
export default function WithProviders(props: AppProps) {
|
export default function WithProviders(props: AppProps) {
|
||||||
return (
|
return (
|
||||||
<SWRConfig
|
<NuqsAdapter>
|
||||||
value={{
|
<SWRConfig
|
||||||
fetcher: (url: string) => network.fetch('GET', url),
|
value={{
|
||||||
shouldRetryOnError: false,
|
fetcher: (url: string) => network.fetch('GET', url),
|
||||||
}}
|
shouldRetryOnError: false,
|
||||||
>
|
}}
|
||||||
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
>
|
||||||
<ActiveProjectProvider>
|
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
|
||||||
<Root {...props} />
|
<ActiveProjectProvider>
|
||||||
</ActiveProjectProvider>
|
<Root {...props} />
|
||||||
</SWRConfig>
|
</ActiveProjectProvider>
|
||||||
|
</SWRConfig>
|
||||||
|
</NuqsAdapter>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout';
|
|||||||
import {ActivityFeed} from '../../components/ActivityFeed';
|
import {ActivityFeed} from '../../components/ActivityFeed';
|
||||||
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
|
||||||
import {NextSeo} from 'next-seo';
|
import {NextSeo} from 'next-seo';
|
||||||
import {useState} from 'react';
|
import {useQueryState, parseAsString} from 'nuqs';
|
||||||
import useSWR from 'swr';
|
import useSWR from 'swr';
|
||||||
|
|
||||||
interface ActivityStats {
|
interface ActivityStats {
|
||||||
@@ -28,8 +28,8 @@ interface ActivityStats {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ActivityPage() {
|
export default function ActivityPage() {
|
||||||
const [typeFilter, setTypeFilter] = useState<string>('ALL');
|
const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL'));
|
||||||
const [dateRange, setDateRange] = useState<string>('30');
|
const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30'));
|
||||||
|
|
||||||
// Fetch activity stats
|
// Fetch activity stats
|
||||||
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
|
||||||
@@ -119,12 +119,14 @@ export default function ActivityPage() {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="ALL">All Activity Types</SelectItem>
|
<SelectItem value="ALL">All Activity Types</SelectItem>
|
||||||
<SelectItem value="event.triggered">Events</SelectItem>
|
<SelectItem value="event.triggered">Events</SelectItem>
|
||||||
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
|
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced,email.complaint">
|
||||||
Emails
|
Emails
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
<SelectItem value="email.sent">Emails Sent</SelectItem>
|
||||||
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
<SelectItem value="email.opened">Emails Opened</SelectItem>
|
||||||
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
<SelectItem value="email.clicked">Emails Clicked</SelectItem>
|
||||||
|
<SelectItem value="email.bounced">Emails Bounced</SelectItem>
|
||||||
|
<SelectItem value="email.complaint">Email Complaints</SelectItem>
|
||||||
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
|
<SelectItem value="workflow.started,workflow.completed">Workflows</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import {WIKI_URI} from '../lib/constants';
|
||||||
|
import type {GetServerSideProps} from 'next';
|
||||||
|
|
||||||
|
export default function Docs() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getServerSideProps: GetServerSideProps = async () => {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: WIKI_URI,
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -118,7 +118,7 @@ export default function TemplateEditorPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!template) {
|
if (!template || Object.keys(editedTemplate).length === 0) {
|
||||||
return (
|
return (
|
||||||
<DashboardLayout>
|
<DashboardLayout>
|
||||||
<div className="flex items-center justify-center min-h-[400px]">
|
<div className="flex items-center justify-center min-h-[400px]">
|
||||||
|
|||||||
+1154
-727
File diff suppressed because it is too large
Load Diff
@@ -44,3 +44,31 @@ Certain keys are reserved by the system and automatically set by Plunk:
|
|||||||
| Key | Description |
|
| Key | Description |
|
||||||
|-----|-------------|
|
|-----|-------------|
|
||||||
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
|
| locale | The contact's preferred locale in ISO 639 (e.g. 'en', 'fr', 'es'). Specifying the locale field on a contact will override the project-wide locale for contact-facing pages and email footers |
|
||||||
|
|
||||||
|
## Subscription State
|
||||||
|
|
||||||
|
Every contact has a `subscribed` field that determines which types of emails they will receive. A newly created contact is subscribed by default.
|
||||||
|
|
||||||
|
### How contacts become unsubscribed
|
||||||
|
|
||||||
|
A contact can become unsubscribed in several ways:
|
||||||
|
- **Manually** through the dashboard or via the API
|
||||||
|
- **Self-service** by clicking the unsubscribe link in an email
|
||||||
|
- **Automatically** when an email to the contact bounces or results in a complaint
|
||||||
|
|
||||||
|
### Emails by subscription state
|
||||||
|
|
||||||
|
The subscription state controls whether a contact receives marketing emails. Transactional emails are always delivered regardless of subscription state.
|
||||||
|
|
||||||
|
| Email type | Subscribed | Unsubscribed |
|
||||||
|
|---|---|---|
|
||||||
|
| **Transactional** (via [/v1/send](/api-reference/public-api/sendTransactionalEmail)) | Delivered | Delivered |
|
||||||
|
| **Campaigns** | Delivered | Not delivered |
|
||||||
|
| **Automations** (transactional template) | Delivered | Delivered |
|
||||||
|
| **Automations** (marketing template) | Delivered | Not delivered |
|
||||||
|
|
||||||
|
<Callout
|
||||||
|
title="Transactional emails and marketing templates"
|
||||||
|
variant="warn">
|
||||||
|
Even when using the transactional API endpoint (`/v1/send`), you cannot send a marketing template to an unsubscribed contact. Use a transactional template instead if the email must reach unsubscribed contacts.
|
||||||
|
</Callout>
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization"]
|
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
---
|
||||||
|
title: Webhooks
|
||||||
|
description: Send real-time event data from Plunk to your own application using webhooks
|
||||||
|
icon: Webhook
|
||||||
|
---
|
||||||
|
|
||||||
|
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
Webhooks in Plunk are powered by the workflow system. The basic flow is:
|
||||||
|
|
||||||
|
1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
|
||||||
|
2. A workflow is triggered by that event
|
||||||
|
3. The workflow executes a **Webhook** step, sending an HTTP request to your URL with relevant data
|
||||||
|
|
||||||
|
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
|
||||||
|
|
||||||
|
## Internal events
|
||||||
|
|
||||||
|
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
|
||||||
|
|
||||||
|
### Email events
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `email.sent` | An email was successfully sent |
|
||||||
|
| `email.delivery` | An email was delivered to the recipient |
|
||||||
|
| `email.open` | A contact opened an email for the first time |
|
||||||
|
| `email.click` | A contact clicked a link in an email for the first time |
|
||||||
|
| `email.bounce` | An email bounced (hard or soft bounce) |
|
||||||
|
| `email.complaint` | A contact marked an email as spam |
|
||||||
|
|
||||||
|
### Contact events
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `contact.subscribed` | A contact's subscription status changed to subscribed |
|
||||||
|
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
|
||||||
|
|
||||||
|
### Segment events
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `segment.<name>.entry` | A contact entered a segment |
|
||||||
|
| `segment.<name>.exit` | A contact exited a segment |
|
||||||
|
|
||||||
|
<Callout
|
||||||
|
title="Segment event names"
|
||||||
|
variant="idea">
|
||||||
|
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Setting up a webhook
|
||||||
|
|
||||||
|
<div className='fd-steps [&_h3]:fd-step'>
|
||||||
|
|
||||||
|
### Create the workflow
|
||||||
|
|
||||||
|
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
|
||||||
|
|
||||||
|
### Add a Webhook step
|
||||||
|
|
||||||
|
After the trigger, add a **Webhook** step and configure it:
|
||||||
|
|
||||||
|
- **URL**: The endpoint on your server that will receive the webhook (e.g. `https://api.example.com/webhooks/plunk`)
|
||||||
|
- **Method**: The HTTP method to use. Defaults to `POST`, which is recommended for most use cases.
|
||||||
|
- **Headers** (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Authorization": "Bearer your-secret-token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Enable the workflow
|
||||||
|
|
||||||
|
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Webhook payload
|
||||||
|
|
||||||
|
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"contact": {
|
||||||
|
"email": "[email protected]",
|
||||||
|
"subscribed": true,
|
||||||
|
"data": {
|
||||||
|
"name": "John",
|
||||||
|
"plan": "pro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"workflow": {
|
||||||
|
"id": "wf_abc123",
|
||||||
|
"name": "Bounce Notifications"
|
||||||
|
},
|
||||||
|
"execution": {
|
||||||
|
"id": "exec_xyz789",
|
||||||
|
"startedAt": "2025-01-15T10:30:00.000Z"
|
||||||
|
},
|
||||||
|
"event": {
|
||||||
|
"subject": "Welcome to Plunk",
|
||||||
|
"from": "[email protected]",
|
||||||
|
"bounceType": "Permanent"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `event` field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
|
||||||
|
|
||||||
|
### Event data by type
|
||||||
|
|
||||||
|
The `event` field varies depending on which event triggered the workflow:
|
||||||
|
|
||||||
|
| Event | Fields in `event` |
|
||||||
|
|-------|-------------------|
|
||||||
|
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
|
||||||
|
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
|
||||||
|
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
|
||||||
|
| `email.bounce` | `subject`, `from`, `bounceType`, `bouncedAt` |
|
||||||
|
| `email.complaint` | `subject`, `from`, `complainedAt` |
|
||||||
|
| Custom events | Whatever data you passed when tracking the event |
|
||||||
|
|
||||||
|
## Common use cases
|
||||||
|
|
||||||
|
### Bounce and complaint monitoring
|
||||||
|
|
||||||
|
Create a workflow triggered by `email.bounce` or `email.complaint` to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
|
||||||
|
|
||||||
|
You can use additional workflow steps before the webhook to add logic:
|
||||||
|
|
||||||
|
- **Condition**: Only send the webhook for hard bounces by checking the `bounceType` field
|
||||||
|
- **Delay**: Add a short delay to batch-process related events
|
||||||
|
- **Update Contact**: Mark the contact with metadata before sending the webhook
|
||||||
|
|
||||||
|
### Syncing unsubscribes
|
||||||
|
|
||||||
|
Trigger a workflow on `contact.unsubscribed` to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
|
||||||
|
|
||||||
|
### Custom event forwarding
|
||||||
|
|
||||||
|
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
|
||||||
|
|
||||||
|
## Adding conditions and delays
|
||||||
|
|
||||||
|
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
|
||||||
|
|
||||||
|
- Use a **Condition** step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
|
||||||
|
- Use a **Wait for Event** step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
|
||||||
|
- Use a **Delay** step to add a time buffer before the webhook fires
|
||||||
+22
-3
@@ -596,7 +596,7 @@
|
|||||||
"post": {
|
"post": {
|
||||||
"tags": ["Public API"],
|
"tags": ["Public API"],
|
||||||
"summary": "Verify email address",
|
"summary": "Verify email address",
|
||||||
"description": "Verify an email address for validity, check if it's from a disposable domain, verify MX records, and detect potential typos with suggestions.",
|
"description": "Verify an email address for validity, check if it's from a disposable domain or personal email provider, verify MX records, and detect potential typos with suggestions.",
|
||||||
"operationId": "verifyEmail",
|
"operationId": "verifyEmail",
|
||||||
"requestBody": {
|
"requestBody": {
|
||||||
"required": true,
|
"required": true,
|
||||||
@@ -676,9 +676,17 @@
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether the email uses plus addressing (contains a + in the local part)"
|
"description": "Whether the email uses plus addressing (contains a + in the local part)"
|
||||||
},
|
},
|
||||||
|
"isPersonalEmail": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the email is from a personal/free email provider (Gmail, Hotmail, Yahoo, etc.)"
|
||||||
|
},
|
||||||
"domainExists": {
|
"domainExists": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Whether the domain exists (has DNS A or AAAA records)"
|
"description": "Whether the domain exists in DNS (has NS records)"
|
||||||
|
},
|
||||||
|
"hasWebsite": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether the domain has a website (has DNS A or AAAA records) - informational only"
|
||||||
},
|
},
|
||||||
"hasMxRecords": {
|
"hasMxRecords": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@@ -705,7 +713,9 @@
|
|||||||
"isAlias",
|
"isAlias",
|
||||||
"isTypo",
|
"isTypo",
|
||||||
"isPlusAddressed",
|
"isPlusAddressed",
|
||||||
|
"isPersonalEmail",
|
||||||
"domainExists",
|
"domainExists",
|
||||||
|
"hasWebsite",
|
||||||
"hasMxRecords",
|
"hasMxRecords",
|
||||||
"reasons"
|
"reasons"
|
||||||
]
|
]
|
||||||
@@ -724,7 +734,9 @@
|
|||||||
"isAlias": false,
|
"isAlias": false,
|
||||||
"isTypo": false,
|
"isTypo": false,
|
||||||
"isPlusAddressed": false,
|
"isPlusAddressed": false,
|
||||||
|
"isPersonalEmail": true,
|
||||||
"domainExists": true,
|
"domainExists": true,
|
||||||
|
"hasWebsite": true,
|
||||||
"hasMxRecords": true,
|
"hasMxRecords": true,
|
||||||
"reasons": ["Email appears to be valid"]
|
"reasons": ["Email appears to be valid"]
|
||||||
}
|
}
|
||||||
@@ -741,10 +753,15 @@
|
|||||||
"isAlias": false,
|
"isAlias": false,
|
||||||
"isTypo": true,
|
"isTypo": true,
|
||||||
"isPlusAddressed": false,
|
"isPlusAddressed": false,
|
||||||
|
"isPersonalEmail": false,
|
||||||
"domainExists": false,
|
"domainExists": false,
|
||||||
|
"hasWebsite": false,
|
||||||
"hasMxRecords": false,
|
"hasMxRecords": false,
|
||||||
"suggestedEmail": "[email protected]",
|
"suggestedEmail": "[email protected]",
|
||||||
"reasons": ["Possible typo detected, did you mean gmail.com?", "Domain does not exist"]
|
"reasons": [
|
||||||
|
"Possible typo detected, did you mean gmail.com?",
|
||||||
|
"Domain does not exist (no nameservers found)"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -759,7 +776,9 @@
|
|||||||
"isAlias": false,
|
"isAlias": false,
|
||||||
"isTypo": false,
|
"isTypo": false,
|
||||||
"isPlusAddressed": false,
|
"isPlusAddressed": false,
|
||||||
|
"isPersonalEmail": false,
|
||||||
"domainExists": true,
|
"domainExists": true,
|
||||||
|
"hasWebsite": true,
|
||||||
"hasMxRecords": true,
|
"hasMxRecords": true,
|
||||||
"reasons": ["Email appears to be valid"]
|
"reasons": ["Email appears to be valid"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"fumadocs-openapi": "^10.0.11",
|
"fumadocs-openapi": "^10.0.11",
|
||||||
"fumadocs-ui": "16.0.8",
|
"fumadocs-ui": "16.0.8",
|
||||||
"lucide-react": "^0.553.0",
|
"lucide-react": "^0.553.0",
|
||||||
"next": "^16.0.10",
|
"next": "^16.1.5",
|
||||||
"next-sitemap": "^4.2.3",
|
"next-sitemap": "^4.2.3",
|
||||||
"react": "^19.2.3",
|
"react": "^19.2.3",
|
||||||
"react-dom": "^19.2.3",
|
"react-dom": "^19.2.3",
|
||||||
|
|||||||
+2
-2
@@ -202,8 +202,8 @@ services:
|
|||||||
|
|
||||||
ports:
|
ports:
|
||||||
# SMTP ports (for email relay)
|
# SMTP ports (for email relay)
|
||||||
- "465:465" # SMTPS (implicit TLS)
|
- "${PORT_SECURE:-465}:465" # SMTPS (implicit TLS)
|
||||||
- "587:587" # SMTP Submission (STARTTLS)
|
- "${PORT_SUBMISSION:-587}:587" # SMTP Submission (STARTTLS)
|
||||||
# Optional: Expose individual service ports for debugging
|
# Optional: Expose individual service ports for debugging
|
||||||
# - "8080:8080" # API
|
# - "8080:8080" # API
|
||||||
# - "3000:3000" # Web
|
# - "3000:3000" # Web
|
||||||
|
|||||||
@@ -25,11 +25,32 @@ PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
|
|||||||
# Output manifest file
|
# Output manifest file
|
||||||
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
|
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
|
||||||
|
|
||||||
# Find all files that contain placeholder URLs and save to manifest
|
# At build time, Next.js creates:
|
||||||
# Search in .next directory for relevant file types
|
# - .next/static/* - Static assets (copied separately to standalone in Dockerfile)
|
||||||
find "$APP_DIR/.next" -type f \( -name "*.js" -o -name "*.json" -o -name "*.html" -o -name "*.rsc" \) \
|
# - .next/standalone/apps/<app>/.next/* - Server files
|
||||||
-exec grep -l -E "$PLACEHOLDER_API|$PLACEHOLDER_DASHBOARD|$PLACEHOLDER_LANDING|$PLACEHOLDER_WIKI" {} \; \
|
#
|
||||||
2>/dev/null > "$MANIFEST_FILE" || true
|
# We need to scan BOTH and store paths relative to where they'll be at runtime:
|
||||||
|
# Runtime location: /app/apps/<app>/.next/standalone/apps/<app>/
|
||||||
|
|
||||||
|
# Clear manifest file
|
||||||
|
> "$MANIFEST_FILE"
|
||||||
|
|
||||||
|
# 1. Scan static assets (these will be copied to standalone/.next/static at runtime)
|
||||||
|
if [ -d "$APP_DIR/.next/static" ]; then
|
||||||
|
echo " Scanning .next/static directory..."
|
||||||
|
find "$APP_DIR/.next/static" -type f \( -name "*.js" -o -name "*.json" -o -name "*.html" \) \
|
||||||
|
-exec grep -l -E "$PLACEHOLDER_API|$PLACEHOLDER_DASHBOARD|$PLACEHOLDER_LANDING|$PLACEHOLDER_WIKI" {} \; \
|
||||||
|
2>/dev/null | sed "s|$APP_DIR/||g" >> "$MANIFEST_FILE" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Scan standalone server files
|
||||||
|
STANDALONE_DIR="$APP_DIR/.next/standalone/apps/$APP_NAME"
|
||||||
|
if [ -d "$STANDALONE_DIR/.next" ]; then
|
||||||
|
echo " Scanning standalone directory..."
|
||||||
|
find "$STANDALONE_DIR/.next" -type f \( -name "*.js" -o -name "*.json" -o -name "*.html" -o -name "*.rsc" \) \
|
||||||
|
-exec grep -l -E "$PLACEHOLDER_API|$PLACEHOLDER_DASHBOARD|$PLACEHOLDER_LANDING|$PLACEHOLDER_WIKI" {} \; \
|
||||||
|
2>/dev/null | sed "s|$STANDALONE_DIR/||g" >> "$MANIFEST_FILE" || true
|
||||||
|
fi
|
||||||
|
|
||||||
# Count files
|
# Count files
|
||||||
FILE_COUNT=$(wc -l < "$MANIFEST_FILE" | tr -d ' ')
|
FILE_COUNT=$(wc -l < "$MANIFEST_FILE" | tr -d ' ')
|
||||||
@@ -37,9 +58,17 @@ FILE_COUNT=$(wc -l < "$MANIFEST_FILE" | tr -d ' ')
|
|||||||
echo "✅ Generated manifest for $APP_NAME: $FILE_COUNT files need URL replacement"
|
echo "✅ Generated manifest for $APP_NAME: $FILE_COUNT files need URL replacement"
|
||||||
echo " Manifest saved to: $MANIFEST_FILE"
|
echo " Manifest saved to: $MANIFEST_FILE"
|
||||||
|
|
||||||
# Also generate manifest for sitemap/robots files
|
# Also generate manifest for sitemap/robots files in standalone public directory
|
||||||
SITEMAP_MANIFEST_FILE="$APP_DIR/.next/sitemap-manifest.txt"
|
SITEMAP_MANIFEST_FILE="$APP_DIR/.next/sitemap-manifest.txt"
|
||||||
find "$APP_DIR/public" -type f \( -name "sitemap*.xml" -o -name "robots.txt" \) 2>/dev/null > "$SITEMAP_MANIFEST_FILE" || true
|
|
||||||
|
# Scan public directory (will be copied to standalone/public at runtime)
|
||||||
|
if [ -d "$APP_DIR/public" ]; then
|
||||||
|
find "$APP_DIR/public" -type f \( -name "sitemap*.xml" -o -name "robots.txt" \) \
|
||||||
|
2>/dev/null | sed "s|$APP_DIR/||g" > "$SITEMAP_MANIFEST_FILE" || true
|
||||||
|
else
|
||||||
|
> "$SITEMAP_MANIFEST_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
SITEMAP_COUNT=$(wc -l < "$SITEMAP_MANIFEST_FILE" | tr -d ' ')
|
SITEMAP_COUNT=$(wc -l < "$SITEMAP_MANIFEST_FILE" | tr -d ' ')
|
||||||
echo "✅ Generated sitemap manifest for $APP_NAME: $SITEMAP_COUNT files"
|
echo "✅ Generated sitemap manifest for $APP_NAME: $SITEMAP_COUNT files"
|
||||||
|
|
||||||
|
|||||||
@@ -37,21 +37,24 @@ replace_urls_in_app() {
|
|||||||
echo " 📊 Processing $file_count files from manifest"
|
echo " 📊 Processing $file_count files from manifest"
|
||||||
|
|
||||||
if [ "$file_count" -gt 0 ]; then
|
if [ "$file_count" -gt 0 ]; then
|
||||||
# Process files using xargs for better performance
|
# Process files line by line
|
||||||
# Use -P 4 to process up to 4 files in parallel
|
# The manifest now contains RELATIVE paths (e.g., .next/static/chunks/abc.js)
|
||||||
# This significantly speeds up processing of many files
|
# We just need to prepend the app_dir
|
||||||
cat "$manifest_file" | xargs -P 4 -I {} sh -c '
|
|
||||||
file="{}"
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
sed -e "s|'"$PLACEHOLDER_API"'|'"$API_URI"'|g" \
|
|
||||||
-e "s|'"$PLACEHOLDER_DASHBOARD"'|'"$DASHBOARD_URI"'|g" \
|
|
||||||
-e "s|'"$PLACEHOLDER_LANDING"'|'"$LANDING_URI"'|g" \
|
|
||||||
-e "s|'"$PLACEHOLDER_WIKI"'|'"$WIKI_URI"'|g" \
|
|
||||||
"$file" > "${file}.tmp" && mv "${file}.tmp" "$file"
|
|
||||||
fi
|
|
||||||
'
|
|
||||||
|
|
||||||
echo " ✅ Successfully replaced URLs in $file_count files"
|
cat "$manifest_file" | while IFS= read -r rel_path; do
|
||||||
|
# Construct full runtime path
|
||||||
|
runtime_path="$app_dir/$rel_path"
|
||||||
|
|
||||||
|
if [ -f "$runtime_path" ]; then
|
||||||
|
sed -e "s|$PLACEHOLDER_API|$API_URI|g" \
|
||||||
|
-e "s|$PLACEHOLDER_DASHBOARD|$DASHBOARD_URI|g" \
|
||||||
|
-e "s|$PLACEHOLDER_LANDING|$LANDING_URI|g" \
|
||||||
|
-e "s|$PLACEHOLDER_WIKI|$WIKI_URI|g" \
|
||||||
|
"$runtime_path" > "${runtime_path}.tmp" && mv "${runtime_path}.tmp" "$runtime_path"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo " ✅ Successfully replaced URLs in files"
|
||||||
else
|
else
|
||||||
echo " ℹ️ No files found with placeholder URLs"
|
echo " ℹ️ No files found with placeholder URLs"
|
||||||
fi
|
fi
|
||||||
@@ -61,14 +64,16 @@ replace_urls_in_app() {
|
|||||||
local sitemap_manifest="$app_dir/.next/sitemap-manifest.txt"
|
local sitemap_manifest="$app_dir/.next/sitemap-manifest.txt"
|
||||||
|
|
||||||
if [ -f "$sitemap_manifest" ]; then
|
if [ -f "$sitemap_manifest" ]; then
|
||||||
while IFS= read -r sitemap_file; do
|
while IFS= read -r rel_path; do
|
||||||
if [ -f "$sitemap_file" ]; then
|
# Manifest contains relative paths, just prepend app_dir
|
||||||
local rel_path="${sitemap_file#$app_dir/}"
|
runtime_sitemap_path="$app_dir/$rel_path"
|
||||||
|
|
||||||
|
if [ -f "$runtime_sitemap_path" ]; then
|
||||||
sed -e "s|$PLACEHOLDER_API|$API_URI|g" \
|
sed -e "s|$PLACEHOLDER_API|$API_URI|g" \
|
||||||
-e "s|$PLACEHOLDER_DASHBOARD|$DASHBOARD_URI|g" \
|
-e "s|$PLACEHOLDER_DASHBOARD|$DASHBOARD_URI|g" \
|
||||||
-e "s|$PLACEHOLDER_LANDING|$LANDING_URI|g" \
|
-e "s|$PLACEHOLDER_LANDING|$LANDING_URI|g" \
|
||||||
-e "s|$PLACEHOLDER_WIKI|$WIKI_URI|g" \
|
-e "s|$PLACEHOLDER_WIKI|$WIKI_URI|g" \
|
||||||
"$sitemap_file" > "${sitemap_file}.tmp" && mv "${sitemap_file}.tmp" "$sitemap_file"
|
"$runtime_sitemap_path" > "${runtime_sitemap_path}.tmp" && mv "${runtime_sitemap_path}.tmp" "$runtime_sitemap_path"
|
||||||
fi
|
fi
|
||||||
done < "$sitemap_manifest"
|
done < "$sitemap_manifest"
|
||||||
else
|
else
|
||||||
|
|||||||
+2
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plunk",
|
"name": "plunk",
|
||||||
"version": "0.4.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "turbo build",
|
"build": "turbo build",
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"clean": "turbo clean --concurrency 20 && rimraf node_modules .turbo",
|
"clean": "turbo clean --concurrency 20 && rimraf node_modules .turbo",
|
||||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||||
"services:up": "docker compose -f docker/docker-compose.dev.yml up -d",
|
"services:up": "docker compose -f docker/docker-compose.dev.yml up -d",
|
||||||
|
"services:down": "docker compose -f docker/docker-compose.dev.yml down",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:watch": "vitest --watch",
|
"test:watch": "vitest --watch",
|
||||||
"test:ui": "vitest --ui",
|
"test:ui": "vitest --ui",
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import nlTranslations from './locales/nl.json' with {type: 'json'};
|
|||||||
import frTranslations from './locales/fr.json' with {type: 'json'};
|
import frTranslations from './locales/fr.json' with {type: 'json'};
|
||||||
import deTranslations from './locales/de.json' with {type: 'json'};
|
import deTranslations from './locales/de.json' with {type: 'json'};
|
||||||
import hiTranslations from './locales/hi.json' with {type: 'json'};
|
import hiTranslations from './locales/hi.json' with {type: 'json'};
|
||||||
|
import ptTranslations from './locales/pt.json' with {type: 'json'};
|
||||||
|
import bgTranslations from './locales/bg.json' with {type: 'json'};
|
||||||
|
import csTranslations from './locales/cs.json' with {type: 'json'};
|
||||||
|
import plTranslations from './locales/pl.json' with {type: 'json'};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
DEFAULT_LANGUAGE,
|
DEFAULT_LANGUAGE,
|
||||||
@@ -34,6 +39,10 @@ const translationsMap: Record<string, Translations> = {
|
|||||||
de: deTranslations,
|
de: deTranslations,
|
||||||
fr: frTranslations,
|
fr: frTranslations,
|
||||||
hi: hiTranslations,
|
hi: hiTranslations,
|
||||||
|
pt: ptTranslations,
|
||||||
|
bg: bgTranslations,
|
||||||
|
cs: csTranslations,
|
||||||
|
pl: plTranslations,
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-memory cache for loaded translations
|
// In-memory cache for loaded translations
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export const SUPPORTED_LANGUAGES: Language[] = [
|
|||||||
{code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷'},
|
{code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷'},
|
||||||
{code: 'hi', name: 'Hindi', nativeName: 'हिंदी', flag: '🇮🇳'},
|
{code: 'hi', name: 'Hindi', nativeName: 'हिंदी', flag: '🇮🇳'},
|
||||||
{code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'},
|
{code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'},
|
||||||
|
{code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷'},
|
||||||
|
{code: 'bg', name: 'Bulgarian', nativeName: 'Български', flag: '🇧🇬'},
|
||||||
|
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
|
||||||
|
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_LANGUAGE = 'en';
|
export const DEFAULT_LANGUAGE = 'en';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"unsubscribe": {
|
||||||
|
"title": "Отписване",
|
||||||
|
"description": "Съжаляваме, че си тръгвате. Сигурни ли сте, че искате да отпишете {email} от получаването на имейли?",
|
||||||
|
"button": "Отпиши",
|
||||||
|
"buttonLoading": "Отписване...",
|
||||||
|
"managePreferences": "Управлявай настройки",
|
||||||
|
"successTitle": "Отписани сте",
|
||||||
|
"successDescription": "{email} е отписан. Няма да получавате повече имейли от нас.",
|
||||||
|
"changedMind": "Размислихте?",
|
||||||
|
"subscribeAgain": "Абонирай се пак"
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"title": "Абонамент за новини",
|
||||||
|
"description": "Искате ли да абонирате {email} за имейли?",
|
||||||
|
"button": "Абонирай",
|
||||||
|
"buttonLoading": "Абониране...",
|
||||||
|
"successTitle": "Абонирани сте!",
|
||||||
|
"successDescription": "{email} вече е абониран за имейли от нас."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Настройки",
|
||||||
|
"description": "Настройки за {email}",
|
||||||
|
"subscriptionLabel": "Абонамент",
|
||||||
|
"subscribedStatus": "В момента сте абониран",
|
||||||
|
"unsubscribedStatus": "В момента сте отписан",
|
||||||
|
"subscribedSuccess": "Успешно абониране!",
|
||||||
|
"unsubscribedSuccess": "Успешно отписване!",
|
||||||
|
"unsubscribeCompletely": "Пълно отписване",
|
||||||
|
"subscribeToEmails": "Абонирай за имейли",
|
||||||
|
"disclaimer": "Тук управлявате имейл настройките си. Статусът се обновява веднага."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Зарежда...",
|
||||||
|
"error": "Грешка"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"footer": {
|
||||||
|
"unsubscribeText": "Получихте този имейл, защото сте се съгласили да получавате имейли от {projectName}. Ако не желаете повече такива имейли, моля",
|
||||||
|
"updatePreferences": "променете настройките"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"unsubscribe": {
|
||||||
|
"title": "Odhlášení z odběru",
|
||||||
|
"description": "Mrzí nás, že odcházíte. Opravdu chcete odhlásit {email} z odběru e-mailů?",
|
||||||
|
"button": "Odhlásit se",
|
||||||
|
"buttonLoading": "Odhlašování...",
|
||||||
|
"managePreferences": "Spravovat předvolby",
|
||||||
|
"successTitle": "Odběr byl zrušen",
|
||||||
|
"successDescription": "E-mail {email} byl odhlášen z odběru. Už vám nebudeme zasílat žádné e-maily.",
|
||||||
|
"changedMind": "Změnili jste názor?",
|
||||||
|
"subscribeAgain": "Přihlásit se znovu"
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"title": "Přihlášení k odběru",
|
||||||
|
"description": "Chcete přihlásit {email} k odběru e-mailů?",
|
||||||
|
"button": "Přihlásit se k odběru",
|
||||||
|
"buttonLoading": "Přihlašování...",
|
||||||
|
"successTitle": "Odběr byl aktivován!",
|
||||||
|
"successDescription": "E-mail {email} byl přihlášen k odběru e-mailů."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Správa předvoleb",
|
||||||
|
"description": "Správa e-mailových předvoleb pro {email}",
|
||||||
|
"subscriptionLabel": "Odběr e-mailů",
|
||||||
|
"subscribedStatus": "Aktuálně jste přihlášeni k odběru e-mailů",
|
||||||
|
"unsubscribedStatus": "Aktuálně jste odhlášeni z odběru e-mailů",
|
||||||
|
"subscribedSuccess": "Odběr byl úspěšně aktivován!",
|
||||||
|
"unsubscribedSuccess": "Odběr byl úspěšně zrušen!",
|
||||||
|
"unsubscribeCompletely": "Úplně se odhlásit z odběru",
|
||||||
|
"subscribeToEmails": "Přihlásit se k odběru e-mailů",
|
||||||
|
"disclaimer": "Na této stránce můžete spravovat své e-mailové předvolby. Stav odběru se aktualizuje v reálném čase."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Načítání...",
|
||||||
|
"error": "Chyba"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"footer": {
|
||||||
|
"unsubscribeText": "Tento e-mail jste obdrželi, protože jste souhlasili se zasíláním e-mailů od {projectName}. Pokud si již nepřejete dostávat tyto e-maily, můžete",
|
||||||
|
"updatePreferences": "změnit své předvolby"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"unsubscribe": {
|
||||||
|
"title": "Wypisz się",
|
||||||
|
"description": "Przykro nam, że odchodzisz. Czy na pewno chcesz wypisać {email} z otrzymywania wiadomości?",
|
||||||
|
"button": "Wypisz się",
|
||||||
|
"buttonLoading": "Wypisywanie...",
|
||||||
|
"managePreferences": "Zarządzaj preferencjami zamiast tego",
|
||||||
|
"successTitle": "Wypisano",
|
||||||
|
"successDescription": "{email} został wypisany. Nie będziesz już otrzymywać od nas wiadomości.",
|
||||||
|
"changedMind": "Zmieniłeś(-aś) zdanie?",
|
||||||
|
"subscribeAgain": "Zapisz się ponownie"
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"title": "Zapisz się na aktualizacje",
|
||||||
|
"description": "Czy chcesz zapisać {email} do otrzymywania wiadomości?",
|
||||||
|
"button": "Zapisz się",
|
||||||
|
"buttonLoading": "Zapisywanie...",
|
||||||
|
"successTitle": "Zapisano!",
|
||||||
|
"successDescription": "{email} jest teraz zapisany do otrzymywania od nas wiadomości."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Zarządzaj preferencjami",
|
||||||
|
"description": "Zarządzaj preferencjami e-mail dla adresu {email}",
|
||||||
|
"subscriptionLabel": "Subskrypcja e-mail",
|
||||||
|
"subscribedStatus": "Obecnie jesteś zapisany do otrzymywania wiadomości",
|
||||||
|
"unsubscribedStatus": "Obecnie jesteś wypisany z otrzymywania wiadomości",
|
||||||
|
"subscribedSuccess": "Pomyślnie zapisano!",
|
||||||
|
"unsubscribedSuccess": "Pomyślnie wypisano!",
|
||||||
|
"unsubscribeCompletely": "Wypisz się całkowicie",
|
||||||
|
"subscribeToEmails": "Zapisz się na e-maile",
|
||||||
|
"disclaimer": "Ta strona pozwala zarządzać preferencjami e-mail. Status subskrypcji jest aktualizowany w czasie rzeczywistym."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Ładowanie...",
|
||||||
|
"error": "Błąd"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"footer": {
|
||||||
|
"unsubscribeText": "Otrzymujesz tę wiadomość, ponieważ wyraziłeś(-aś) zgodę na otrzymywanie wiadomości od {projectName}. Jeśli nie chcesz już otrzymywać takich wiadomości, prosimy",
|
||||||
|
"updatePreferences": "zaktualizuj swoje preferencje"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"pages": {
|
||||||
|
"unsubscribe": {
|
||||||
|
"title": "Cancelar inscrição",
|
||||||
|
"description": "Lamentamos vê-lo partir. Tem certeza de que deseja cancelar a inscrição de {email} para não receber mais e-mails?",
|
||||||
|
"button": "Cancelar inscrição",
|
||||||
|
"buttonLoading": "Cancelando inscrição...",
|
||||||
|
"managePreferences": "Gerenciar preferências",
|
||||||
|
"successTitle": "Inscrição cancelada",
|
||||||
|
"successDescription": "{email} foi removido da lista. Você não receberá mais e-mails nossos.",
|
||||||
|
"changedMind": "Mudou de ideia?",
|
||||||
|
"subscribeAgain": "Inscrever-se novamente"
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"title": "Inscrever-se para atualizações",
|
||||||
|
"description": "Deseja inscrever {email} para receber e-mails?",
|
||||||
|
"button": "Inscrever-se",
|
||||||
|
"buttonLoading": "Inscrevendo...",
|
||||||
|
"successTitle": "Você está inscrito!",
|
||||||
|
"successDescription": "{email} agora está inscrito para receber nossos e-mails."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Gerenciar Preferências",
|
||||||
|
"description": "Gerenciar preferências de e-mail para {email}",
|
||||||
|
"subscriptionLabel": "Inscrição de E-mail",
|
||||||
|
"subscribedStatus": "Você está inscrito para receber e-mails",
|
||||||
|
"unsubscribedStatus": "Você cancelou a inscrição de e-mails",
|
||||||
|
"subscribedSuccess": "Inscrito com sucesso!",
|
||||||
|
"unsubscribedSuccess": "Inscrição cancelada com sucesso!",
|
||||||
|
"unsubscribeCompletely": "Cancelar inscrição completamente",
|
||||||
|
"subscribeToEmails": "Inscrever-se para e-mails",
|
||||||
|
"disclaimer": "Esta página permite que você gerencie suas preferências de e-mail. Seu status de inscrição é atualizado em tempo real."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Carregando...",
|
||||||
|
"error": "Erro"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"footer": {
|
||||||
|
"unsubscribeText": "Você recebeu este e-mail porque concordou em receber e-mails de {projectName}. Se não deseja mais receber e-mails como este, por favor",
|
||||||
|
"updatePreferences": "atualize suas preferências"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,8 +101,10 @@ const segmentFilterSchema = z.object({
|
|||||||
'exists',
|
'exists',
|
||||||
'notExists',
|
'notExists',
|
||||||
'within',
|
'within',
|
||||||
|
'olderThan',
|
||||||
'triggered',
|
'triggered',
|
||||||
'triggeredWithin',
|
'triggeredWithin',
|
||||||
|
'triggeredOlderThan',
|
||||||
'notTriggered',
|
'notTriggered',
|
||||||
]),
|
]),
|
||||||
value: z.any().optional(),
|
value: z.any().optional(),
|
||||||
@@ -206,6 +208,27 @@ export const WorkflowSchemas = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const WorkflowStepConfigSchemas = {
|
export const WorkflowStepConfigSchemas = {
|
||||||
|
sendEmail: z.object({
|
||||||
|
templateId: uuid,
|
||||||
|
recipient: z
|
||||||
|
.object({
|
||||||
|
type: z.enum(['CONTACT', 'CUSTOM']),
|
||||||
|
customEmail: email.optional(),
|
||||||
|
})
|
||||||
|
.refine(
|
||||||
|
data => {
|
||||||
|
// If type is CUSTOM, customEmail must be provided
|
||||||
|
if (data.type === 'CUSTOM') {
|
||||||
|
return !!data.customEmail;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
message: 'Custom email is required when recipient type is CUSTOM',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
}),
|
||||||
delay: z
|
delay: z
|
||||||
.object({
|
.object({
|
||||||
amount: z.number().positive(),
|
amount: z.number().positive(),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export enum ActivityType {
|
|||||||
EMAIL_OPENED = 'email.opened',
|
EMAIL_OPENED = 'email.opened',
|
||||||
EMAIL_CLICKED = 'email.clicked',
|
EMAIL_CLICKED = 'email.clicked',
|
||||||
EMAIL_BOUNCED = 'email.bounced',
|
EMAIL_BOUNCED = 'email.bounced',
|
||||||
|
EMAIL_COMPLAINT = 'email.complaint',
|
||||||
CAMPAIGN_SENT = 'campaign.sent',
|
CAMPAIGN_SENT = 'campaign.sent',
|
||||||
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
|
||||||
WORKFLOW_STARTED = 'workflow.started',
|
WORKFLOW_STARTED = 'workflow.started',
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export interface EmailVerificationResult {
|
|||||||
isAlias: boolean;
|
isAlias: boolean;
|
||||||
isTypo: boolean;
|
isTypo: boolean;
|
||||||
isPlusAddressed: boolean;
|
isPlusAddressed: boolean;
|
||||||
domainExists: boolean;
|
isPersonalEmail: boolean; // Email is from a personal/free provider (Gmail, Hotmail, etc.)
|
||||||
hasMxRecords: boolean;
|
domainExists: boolean; // Domain exists in DNS (has NS records)
|
||||||
|
hasWebsite: boolean; // Domain has A/AAAA records (informational - not required for email)
|
||||||
|
hasMxRecords: boolean; // Domain has MX records (required for receiving email)
|
||||||
suggestedEmail?: string;
|
suggestedEmail?: string;
|
||||||
reasons: string[];
|
reasons: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ export * from './segments/index.js';
|
|||||||
|
|
||||||
// Security types
|
// Security types
|
||||||
export * from './security/index.js';
|
export * from './security/index.js';
|
||||||
|
|
||||||
|
// Workflow types
|
||||||
|
export * from './workflows/index.js';
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ export type SegmentFilterOperator =
|
|||||||
| 'exists'
|
| 'exists'
|
||||||
| 'notExists'
|
| 'notExists'
|
||||||
| 'within'
|
| 'within'
|
||||||
|
| 'olderThan' // More than X time ago (for date fields)
|
||||||
// Event-based operators
|
// Event-based operators
|
||||||
| 'triggered' // Event/email activity occurred (any time)
|
| 'triggered' // Event/email activity occurred (any time)
|
||||||
| 'triggeredWithin' // Event/email activity occurred within timeframe
|
| 'triggeredWithin' // Event/email activity occurred within timeframe
|
||||||
|
| 'triggeredOlderThan' // Event/email activity occurred more than X time ago
|
||||||
| 'notTriggered'; // Event/email activity never occurred
|
| 'notTriggered'; // Event/email activity never occurred
|
||||||
|
|
||||||
export type SegmentFilterLogic = 'AND' | 'OR';
|
export type SegmentFilterLogic = 'AND' | 'OR';
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* Workflow-specific type definitions
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recipient type for workflow email steps
|
||||||
|
*/
|
||||||
|
export enum EmailRecipientType {
|
||||||
|
/** Send to the contact that triggered the workflow */
|
||||||
|
CONTACT = 'CONTACT',
|
||||||
|
/** Send to a custom email address */
|
||||||
|
CUSTOM = 'CUSTOM',
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for email recipient in SEND_EMAIL workflow step
|
||||||
|
*/
|
||||||
|
export interface EmailRecipientConfig {
|
||||||
|
/** Type of recipient */
|
||||||
|
type: EmailRecipientType;
|
||||||
|
/** Custom email address (required when type is CUSTOM) */
|
||||||
|
customEmail?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for SEND_EMAIL workflow step
|
||||||
|
*/
|
||||||
|
export interface SendEmailStepConfig {
|
||||||
|
/** Template ID to use for the email */
|
||||||
|
templateId: string;
|
||||||
|
/** Recipient configuration */
|
||||||
|
recipient?: EmailRecipientConfig;
|
||||||
|
}
|
||||||
+33
-3
@@ -34,6 +34,7 @@
|
|||||||
"dependsOn": ["^start"],
|
"dependsOn": ["^start"],
|
||||||
"env": [
|
"env": [
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
"PORT",
|
||||||
"JWT_SECRET",
|
"JWT_SECRET",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DIRECT_DATABASE_URL",
|
"DIRECT_DATABASE_URL",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"AWS_SES_SECRET_ACCESS_KEY",
|
"AWS_SES_SECRET_ACCESS_KEY",
|
||||||
"SES_CONFIGURATION_SET",
|
"SES_CONFIGURATION_SET",
|
||||||
"SES_CONFIGURATION_SET_NO_TRACKING",
|
"SES_CONFIGURATION_SET_NO_TRACKING",
|
||||||
|
"EMAIL_RATE_LIMIT_PER_SECOND",
|
||||||
"GITHUB_OAUTH_CLIENT",
|
"GITHUB_OAUTH_CLIENT",
|
||||||
"GITHUB_OAUTH_SECRET",
|
"GITHUB_OAUTH_SECRET",
|
||||||
"GOOGLE_OAUTH_CLIENT",
|
"GOOGLE_OAUTH_CLIENT",
|
||||||
@@ -67,7 +69,15 @@
|
|||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI",
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
"PLUNK_API_KEY",
|
"PLUNK_API_KEY",
|
||||||
"PLUNK_FROM_ADDRESS"
|
"PLUNK_FROM_ADDRESS",
|
||||||
|
"NTFY_URL",
|
||||||
|
"SMTP_ENABLED",
|
||||||
|
"SMTP_DOMAIN",
|
||||||
|
"PORT_SECURE",
|
||||||
|
"PORT_SUBMISSION",
|
||||||
|
"AUTO_PROJECT_DISABLE",
|
||||||
|
"DISABLE_SIGNUPS",
|
||||||
|
"VERIFY_EMAIL_ON_SIGNUP"
|
||||||
],
|
],
|
||||||
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
"inputs": ["$TURBO_DEFAULT$", ".env*"]
|
||||||
},
|
},
|
||||||
@@ -84,6 +94,7 @@
|
|||||||
"persistent": true,
|
"persistent": true,
|
||||||
"env": [
|
"env": [
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
"PORT",
|
||||||
"JWT_SECRET",
|
"JWT_SECRET",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DIRECT_DATABASE_URL",
|
"DIRECT_DATABASE_URL",
|
||||||
@@ -103,6 +114,7 @@
|
|||||||
"AWS_SES_SECRET_ACCESS_KEY",
|
"AWS_SES_SECRET_ACCESS_KEY",
|
||||||
"SES_CONFIGURATION_SET",
|
"SES_CONFIGURATION_SET",
|
||||||
"SES_CONFIGURATION_SET_NO_TRACKING",
|
"SES_CONFIGURATION_SET_NO_TRACKING",
|
||||||
|
"EMAIL_RATE_LIMIT_PER_SECOND",
|
||||||
"GITHUB_OAUTH_CLIENT",
|
"GITHUB_OAUTH_CLIENT",
|
||||||
"GITHUB_OAUTH_SECRET",
|
"GITHUB_OAUTH_SECRET",
|
||||||
"GOOGLE_OAUTH_CLIENT",
|
"GOOGLE_OAUTH_CLIENT",
|
||||||
@@ -117,7 +129,15 @@
|
|||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI",
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
"PLUNK_API_KEY",
|
"PLUNK_API_KEY",
|
||||||
"PLUNK_FROM_ADDRESS"
|
"PLUNK_FROM_ADDRESS",
|
||||||
|
"NTFY_URL",
|
||||||
|
"SMTP_ENABLED",
|
||||||
|
"SMTP_DOMAIN",
|
||||||
|
"PORT_SECURE",
|
||||||
|
"PORT_SUBMISSION",
|
||||||
|
"AUTO_PROJECT_DISABLE",
|
||||||
|
"DISABLE_SIGNUPS",
|
||||||
|
"VERIFY_EMAIL_ON_SIGNUP"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dev:worker": {
|
"dev:worker": {
|
||||||
@@ -125,6 +145,7 @@
|
|||||||
"persistent": true,
|
"persistent": true,
|
||||||
"env": [
|
"env": [
|
||||||
"NODE_ENV",
|
"NODE_ENV",
|
||||||
|
"PORT",
|
||||||
"JWT_SECRET",
|
"JWT_SECRET",
|
||||||
"DATABASE_URL",
|
"DATABASE_URL",
|
||||||
"DIRECT_DATABASE_URL",
|
"DIRECT_DATABASE_URL",
|
||||||
@@ -144,6 +165,7 @@
|
|||||||
"AWS_SES_SECRET_ACCESS_KEY",
|
"AWS_SES_SECRET_ACCESS_KEY",
|
||||||
"SES_CONFIGURATION_SET",
|
"SES_CONFIGURATION_SET",
|
||||||
"SES_CONFIGURATION_SET_NO_TRACKING",
|
"SES_CONFIGURATION_SET_NO_TRACKING",
|
||||||
|
"EMAIL_RATE_LIMIT_PER_SECOND",
|
||||||
"GITHUB_OAUTH_CLIENT",
|
"GITHUB_OAUTH_CLIENT",
|
||||||
"GITHUB_OAUTH_SECRET",
|
"GITHUB_OAUTH_SECRET",
|
||||||
"GOOGLE_OAUTH_CLIENT",
|
"GOOGLE_OAUTH_CLIENT",
|
||||||
@@ -158,7 +180,15 @@
|
|||||||
"NEXT_PUBLIC_DASHBOARD_URI",
|
"NEXT_PUBLIC_DASHBOARD_URI",
|
||||||
"NEXT_PUBLIC_WIKI_URI",
|
"NEXT_PUBLIC_WIKI_URI",
|
||||||
"PLUNK_API_KEY",
|
"PLUNK_API_KEY",
|
||||||
"PLUNK_FROM_ADDRESS"
|
"PLUNK_FROM_ADDRESS",
|
||||||
|
"NTFY_URL",
|
||||||
|
"SMTP_ENABLED",
|
||||||
|
"SMTP_DOMAIN",
|
||||||
|
"PORT_SECURE",
|
||||||
|
"PORT_SUBMISSION",
|
||||||
|
"AUTO_PROJECT_DISABLE",
|
||||||
|
"DISABLE_SIGNUPS",
|
||||||
|
"VERIFY_EMAIL_ON_SIGNUP"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"clean": {
|
"clean": {
|
||||||
|
|||||||
@@ -2821,10 +2821,10 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/env@npm:16.0.10":
|
"@next/env@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/env@npm:16.0.10"
|
resolution: "@next/env@npm:16.1.6"
|
||||||
checksum: 10c0/6f2b5ba37733a49513ab3117c06f037fa962b4022fe2b4ba7801f830ab15974ad87ad8b47b6464fa5fcb3e9a3247b25f657ec97ae6eed1156b5736c6388246db
|
checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -2851,9 +2851,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-darwin-arm64@npm:16.0.10":
|
"@next/swc-darwin-arm64@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-darwin-arm64@npm:16.0.10"
|
resolution: "@next/swc-darwin-arm64@npm:16.1.6"
|
||||||
conditions: os=darwin & cpu=arm64
|
conditions: os=darwin & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2865,9 +2865,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-darwin-x64@npm:16.0.10":
|
"@next/swc-darwin-x64@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-darwin-x64@npm:16.0.10"
|
resolution: "@next/swc-darwin-x64@npm:16.1.6"
|
||||||
conditions: os=darwin & cpu=x64
|
conditions: os=darwin & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2879,9 +2879,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-arm64-gnu@npm:16.0.10":
|
"@next/swc-linux-arm64-gnu@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.0.10"
|
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6"
|
||||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2893,9 +2893,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-arm64-musl@npm:16.0.10":
|
"@next/swc-linux-arm64-musl@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-linux-arm64-musl@npm:16.0.10"
|
resolution: "@next/swc-linux-arm64-musl@npm:16.1.6"
|
||||||
conditions: os=linux & cpu=arm64 & libc=musl
|
conditions: os=linux & cpu=arm64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2907,9 +2907,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-x64-gnu@npm:16.0.10":
|
"@next/swc-linux-x64-gnu@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-linux-x64-gnu@npm:16.0.10"
|
resolution: "@next/swc-linux-x64-gnu@npm:16.1.6"
|
||||||
conditions: os=linux & cpu=x64 & libc=glibc
|
conditions: os=linux & cpu=x64 & libc=glibc
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2921,9 +2921,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-linux-x64-musl@npm:16.0.10":
|
"@next/swc-linux-x64-musl@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-linux-x64-musl@npm:16.0.10"
|
resolution: "@next/swc-linux-x64-musl@npm:16.1.6"
|
||||||
conditions: os=linux & cpu=x64 & libc=musl
|
conditions: os=linux & cpu=x64 & libc=musl
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2935,9 +2935,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-win32-arm64-msvc@npm:16.0.10":
|
"@next/swc-win32-arm64-msvc@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.0.10"
|
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6"
|
||||||
conditions: os=win32 & cpu=arm64
|
conditions: os=win32 & cpu=arm64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -2949,9 +2949,9 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@next/swc-win32-x64-msvc@npm:16.0.10":
|
"@next/swc-win32-x64-msvc@npm:16.1.6":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "@next/swc-win32-x64-msvc@npm:16.0.10"
|
resolution: "@next/swc-win32-x64-msvc@npm:16.1.6"
|
||||||
conditions: os=win32 & cpu=x64
|
conditions: os=win32 & cpu=x64
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
@@ -8182,6 +8182,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"baseline-browser-mapping@npm:^2.8.3":
|
||||||
|
version: 2.9.18
|
||||||
|
resolution: "baseline-browser-mapping@npm:2.9.18"
|
||||||
|
bin:
|
||||||
|
baseline-browser-mapping: dist/cli.js
|
||||||
|
checksum: 10c0/869bdbb2784f8b1bc49b52d54ea48bf9ea6da8309195e3a0b3f4625197b8187a9b557b1d02f1b6b6dd51f163840a87db259e2b791eed35f0c5fddf3110c4cf28
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"baseline-browser-mapping@npm:^2.9.0":
|
"baseline-browser-mapping@npm:^2.9.0":
|
||||||
version: 2.9.6
|
version: 2.9.6
|
||||||
resolution: "baseline-browser-mapping@npm:2.9.6"
|
resolution: "baseline-browser-mapping@npm:2.9.6"
|
||||||
@@ -8308,7 +8317,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.26.3":
|
"browserslist@npm:^4.24.0, browserslist@npm:^4.24.4, browserslist@npm:^4.28.1":
|
||||||
version: 4.28.1
|
version: 4.28.1
|
||||||
resolution: "browserslist@npm:4.28.1"
|
resolution: "browserslist@npm:4.28.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9504,9 +9513,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"diff@npm:^4.0.1":
|
"diff@npm:^4.0.1":
|
||||||
version: 4.0.2
|
version: 4.0.4
|
||||||
resolution: "diff@npm:4.0.2"
|
resolution: "diff@npm:4.0.4"
|
||||||
checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1
|
checksum: 10c0/855fb70b093d1d9643ddc12ea76dca90dc9d9cdd7f82c08ee8b9325c0dc5748faf3c82e2047ced5dcaa8b26e58f7903900be2628d0380a222c02d79d8de385df
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -9811,7 +9820,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"enhanced-resolve@npm:^5.17.3, enhanced-resolve@npm:^5.18.3":
|
"enhanced-resolve@npm:^5.18.3":
|
||||||
version: 5.18.4
|
version: 5.18.4
|
||||||
resolution: "enhanced-resolve@npm:5.18.4"
|
resolution: "enhanced-resolve@npm:5.18.4"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -9821,6 +9830,16 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"enhanced-resolve@npm:^5.19.0":
|
||||||
|
version: 5.19.0
|
||||||
|
resolution: "enhanced-resolve@npm:5.19.0"
|
||||||
|
dependencies:
|
||||||
|
graceful-fs: "npm:^4.2.4"
|
||||||
|
tapable: "npm:^2.3.0"
|
||||||
|
checksum: 10c0/966b1dffb82d5f6a4d6a86e904e812104a999066aa29f9223040aaa751e7c453b462a3f5ef91f8bd4408131ff6f7f90651dd1c804bdcb7944e2099a9c2e45ee2
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"entities@npm:^2.0.0":
|
"entities@npm:^2.0.0":
|
||||||
version: 2.2.0
|
version: 2.2.0
|
||||||
resolution: "entities@npm:2.2.0"
|
resolution: "entities@npm:2.2.0"
|
||||||
@@ -9979,13 +9998,20 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"es-module-lexer@npm:^1.2.1, es-module-lexer@npm:^1.7.0":
|
"es-module-lexer@npm:^1.7.0":
|
||||||
version: 1.7.0
|
version: 1.7.0
|
||||||
resolution: "es-module-lexer@npm:1.7.0"
|
resolution: "es-module-lexer@npm:1.7.0"
|
||||||
checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b
|
checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
"es-module-lexer@npm:^2.0.0":
|
||||||
|
version: 2.0.0
|
||||||
|
resolution: "es-module-lexer@npm:2.0.0"
|
||||||
|
checksum: 10c0/ae78dbbd43035a4b972c46cfb6877e374ea290adfc62bc2f5a083fea242c0b2baaab25c5886af86be55f092f4a326741cb94334cd3c478c383fdc8a9ec5ff817
|
||||||
|
languageName: node
|
||||||
|
linkType: hard
|
||||||
|
|
||||||
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
|
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
|
||||||
version: 1.1.1
|
version: 1.1.1
|
||||||
resolution: "es-object-atoms@npm:1.1.1"
|
resolution: "es-object-atoms@npm:1.1.1"
|
||||||
@@ -12878,7 +12904,7 @@ __metadata:
|
|||||||
eslint-config-next: "npm:^16.0.1"
|
eslint-config-next: "npm:^16.0.1"
|
||||||
juice: "npm:^11.0.3"
|
juice: "npm:^11.0.3"
|
||||||
lucide-react: "npm:^0.553.0"
|
lucide-react: "npm:^0.553.0"
|
||||||
next: "npm:^16.0.10"
|
next: "npm:^16.1.5"
|
||||||
next-seo: "npm:^6.6.0"
|
next-seo: "npm:^6.6.0"
|
||||||
next-sitemap: "npm:^4.2.3"
|
next-sitemap: "npm:^4.2.3"
|
||||||
postcss: "npm:^8.4.33"
|
postcss: "npm:^8.4.33"
|
||||||
@@ -13222,9 +13248,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"lodash@npm:^4.17.15":
|
"lodash@npm:^4.17.15":
|
||||||
version: 4.17.21
|
version: 4.17.23
|
||||||
resolution: "lodash@npm:4.17.21"
|
resolution: "lodash@npm:4.17.23"
|
||||||
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
|
checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -13396,8 +13422,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"markdown-it@npm:^14.0.0":
|
"markdown-it@npm:^14.0.0":
|
||||||
version: 14.1.0
|
version: 14.1.1
|
||||||
resolution: "markdown-it@npm:14.1.0"
|
resolution: "markdown-it@npm:14.1.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse: "npm:^2.0.1"
|
argparse: "npm:^2.0.1"
|
||||||
entities: "npm:^4.4.0"
|
entities: "npm:^4.4.0"
|
||||||
@@ -13407,7 +13433,7 @@ __metadata:
|
|||||||
uc.micro: "npm:^2.1.0"
|
uc.micro: "npm:^2.1.0"
|
||||||
bin:
|
bin:
|
||||||
markdown-it: bin/markdown-it.mjs
|
markdown-it: bin/markdown-it.mjs
|
||||||
checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4
|
checksum: 10c0/c67f2a4c8069a307c78d8c15104bbcb15a2c6b17f4c904364ca218ec2eccf76a397eba1ea05f5ac5de72c4b67fcf115d422d22df0bfb86a09b663f55b9478d4f
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -14641,20 +14667,21 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"next@npm:^16.0.10":
|
"next@npm:^16.1.5":
|
||||||
version: 16.0.10
|
version: 16.1.6
|
||||||
resolution: "next@npm:16.0.10"
|
resolution: "next@npm:16.1.6"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@next/env": "npm:16.0.10"
|
"@next/env": "npm:16.1.6"
|
||||||
"@next/swc-darwin-arm64": "npm:16.0.10"
|
"@next/swc-darwin-arm64": "npm:16.1.6"
|
||||||
"@next/swc-darwin-x64": "npm:16.0.10"
|
"@next/swc-darwin-x64": "npm:16.1.6"
|
||||||
"@next/swc-linux-arm64-gnu": "npm:16.0.10"
|
"@next/swc-linux-arm64-gnu": "npm:16.1.6"
|
||||||
"@next/swc-linux-arm64-musl": "npm:16.0.10"
|
"@next/swc-linux-arm64-musl": "npm:16.1.6"
|
||||||
"@next/swc-linux-x64-gnu": "npm:16.0.10"
|
"@next/swc-linux-x64-gnu": "npm:16.1.6"
|
||||||
"@next/swc-linux-x64-musl": "npm:16.0.10"
|
"@next/swc-linux-x64-musl": "npm:16.1.6"
|
||||||
"@next/swc-win32-arm64-msvc": "npm:16.0.10"
|
"@next/swc-win32-arm64-msvc": "npm:16.1.6"
|
||||||
"@next/swc-win32-x64-msvc": "npm:16.0.10"
|
"@next/swc-win32-x64-msvc": "npm:16.1.6"
|
||||||
"@swc/helpers": "npm:0.5.15"
|
"@swc/helpers": "npm:0.5.15"
|
||||||
|
baseline-browser-mapping: "npm:^2.8.3"
|
||||||
caniuse-lite: "npm:^1.0.30001579"
|
caniuse-lite: "npm:^1.0.30001579"
|
||||||
postcss: "npm:8.4.31"
|
postcss: "npm:8.4.31"
|
||||||
sharp: "npm:^0.34.4"
|
sharp: "npm:^0.34.4"
|
||||||
@@ -14696,7 +14723,7 @@ __metadata:
|
|||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
next: dist/bin/next
|
next: dist/bin/next
|
||||||
checksum: 10c0/c0e42bfaa0d38b0ee9a7fc54c748da2dfd807a76d75278b4c4f06768965d7b1035b836cae45e24a074dcf156a13c0fa555caa652886cc0f93e8be2b296dfbb51
|
checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -15888,11 +15915,11 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0":
|
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0":
|
||||||
version: 6.14.1
|
version: 6.14.2
|
||||||
resolution: "qs@npm:6.14.1"
|
resolution: "qs@npm:6.14.2"
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: "npm:^1.1.0"
|
side-channel: "npm:^1.1.0"
|
||||||
checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613
|
checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -17977,19 +18004,19 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"tar@npm:^7.5.2":
|
"tar@npm:^7.5.2":
|
||||||
version: 7.5.2
|
version: 7.5.7
|
||||||
resolution: "tar@npm:7.5.2"
|
resolution: "tar@npm:7.5.7"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@isaacs/fs-minipass": "npm:^4.0.0"
|
"@isaacs/fs-minipass": "npm:^4.0.0"
|
||||||
chownr: "npm:^3.0.0"
|
chownr: "npm:^3.0.0"
|
||||||
minipass: "npm:^7.1.2"
|
minipass: "npm:^7.1.2"
|
||||||
minizlib: "npm:^3.1.0"
|
minizlib: "npm:^3.1.0"
|
||||||
yallist: "npm:^5.0.0"
|
yallist: "npm:^5.0.0"
|
||||||
checksum: 10c0/a7d8b801139b52f93a7e34830db0de54c5aa45487c7cb551f6f3d44a112c67f1cb8ffdae856b05fd4f17b1749911f1c26f1e3a23bbe0279e17fd96077f13f467
|
checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"terser-webpack-plugin@npm:^5.3.11":
|
"terser-webpack-plugin@npm:^5.3.16":
|
||||||
version: 5.3.16
|
version: 5.3.16
|
||||||
resolution: "terser-webpack-plugin@npm:5.3.16"
|
resolution: "terser-webpack-plugin@npm:5.3.16"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -18517,9 +18544,9 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"undici@npm:^6.19.5":
|
"undici@npm:^6.19.5":
|
||||||
version: 6.22.0
|
version: 6.23.0
|
||||||
resolution: "undici@npm:6.22.0"
|
resolution: "undici@npm:6.23.0"
|
||||||
checksum: 10c0/47903c489d73e26bd47960cf2f04d63282ed050818b672cb05f8dfb6403381b850cf1b1751832654fd3af22aacd9d780e5e61aff563cd97943f5c4f10d5b3e23
|
checksum: 10c0/d846b3fdfd05aa6081ba1eab5db6bbc21b283042c7a43722b86d1ee2bf749d7c990ceac0c809f9a07ffd88b1b0f4c0f548a8362c035088cb1997d63abdda499c
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -19007,13 +19034,13 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"watchpack@npm:^2.4.4":
|
"watchpack@npm:^2.5.1":
|
||||||
version: 2.4.4
|
version: 2.5.1
|
||||||
resolution: "watchpack@npm:2.4.4"
|
resolution: "watchpack@npm:2.5.1"
|
||||||
dependencies:
|
dependencies:
|
||||||
glob-to-regexp: "npm:^0.4.1"
|
glob-to-regexp: "npm:^0.4.1"
|
||||||
graceful-fs: "npm:^4.1.2"
|
graceful-fs: "npm:^4.1.2"
|
||||||
checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275
|
checksum: 10c0/dffbb483d1f61be90dc570630a1eb308581e2227d507d783b1d94a57ac7b705ecd9a1a4b73d73c15eab596d39874e5276a3d9cb88bbb698bafc3f8d08c34cf17
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -19080,7 +19107,7 @@ __metadata:
|
|||||||
framer-motion: "npm:^12.23.24"
|
framer-motion: "npm:^12.23.24"
|
||||||
juice: "npm:^11.0.3"
|
juice: "npm:^11.0.3"
|
||||||
lucide-react: "npm:^0.553.0"
|
lucide-react: "npm:^0.553.0"
|
||||||
next: "npm:^16.0.10"
|
next: "npm:^16.1.5"
|
||||||
next-seo: "npm:^6.6.0"
|
next-seo: "npm:^6.6.0"
|
||||||
next-sitemap: "npm:^4.2.3"
|
next-sitemap: "npm:^4.2.3"
|
||||||
nuqs: "npm:^2.7.3"
|
nuqs: "npm:^2.7.3"
|
||||||
@@ -19108,8 +19135,8 @@ __metadata:
|
|||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"webpack@npm:^5":
|
"webpack@npm:^5":
|
||||||
version: 5.103.0
|
version: 5.105.0
|
||||||
resolution: "webpack@npm:5.103.0"
|
resolution: "webpack@npm:5.105.0"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@types/eslint-scope": "npm:^3.7.7"
|
"@types/eslint-scope": "npm:^3.7.7"
|
||||||
"@types/estree": "npm:^1.0.8"
|
"@types/estree": "npm:^1.0.8"
|
||||||
@@ -19119,10 +19146,10 @@ __metadata:
|
|||||||
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
|
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
|
||||||
acorn: "npm:^8.15.0"
|
acorn: "npm:^8.15.0"
|
||||||
acorn-import-phases: "npm:^1.0.3"
|
acorn-import-phases: "npm:^1.0.3"
|
||||||
browserslist: "npm:^4.26.3"
|
browserslist: "npm:^4.28.1"
|
||||||
chrome-trace-event: "npm:^1.0.2"
|
chrome-trace-event: "npm:^1.0.2"
|
||||||
enhanced-resolve: "npm:^5.17.3"
|
enhanced-resolve: "npm:^5.19.0"
|
||||||
es-module-lexer: "npm:^1.2.1"
|
es-module-lexer: "npm:^2.0.0"
|
||||||
eslint-scope: "npm:5.1.1"
|
eslint-scope: "npm:5.1.1"
|
||||||
events: "npm:^3.2.0"
|
events: "npm:^3.2.0"
|
||||||
glob-to-regexp: "npm:^0.4.1"
|
glob-to-regexp: "npm:^0.4.1"
|
||||||
@@ -19133,15 +19160,15 @@ __metadata:
|
|||||||
neo-async: "npm:^2.6.2"
|
neo-async: "npm:^2.6.2"
|
||||||
schema-utils: "npm:^4.3.3"
|
schema-utils: "npm:^4.3.3"
|
||||||
tapable: "npm:^2.3.0"
|
tapable: "npm:^2.3.0"
|
||||||
terser-webpack-plugin: "npm:^5.3.11"
|
terser-webpack-plugin: "npm:^5.3.16"
|
||||||
watchpack: "npm:^2.4.4"
|
watchpack: "npm:^2.5.1"
|
||||||
webpack-sources: "npm:^3.3.3"
|
webpack-sources: "npm:^3.3.3"
|
||||||
peerDependenciesMeta:
|
peerDependenciesMeta:
|
||||||
webpack-cli:
|
webpack-cli:
|
||||||
optional: true
|
optional: true
|
||||||
bin:
|
bin:
|
||||||
webpack: bin/webpack.js
|
webpack: bin/webpack.js
|
||||||
checksum: 10c0/d0cf86f8cac249874d6f36292e25011413ebb5bae82c48fa78a165a217e63db00b1a1f563f5195070eb17a055c6da4b6ab89fbdd37f781abdda862aa8c0bd623
|
checksum: 10c0/4aea6b976485b5364e122f301c08f48efa84ddb2c0cb5d09f27445d1f2da0b9875cd889e41b58cac3ff05618a9c965be716df52586d151b5f52a7bbed7662174
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
@@ -19277,7 +19304,7 @@ __metadata:
|
|||||||
fumadocs-openapi: "npm:^10.0.11"
|
fumadocs-openapi: "npm:^10.0.11"
|
||||||
fumadocs-ui: "npm:16.0.8"
|
fumadocs-ui: "npm:16.0.8"
|
||||||
lucide-react: "npm:^0.553.0"
|
lucide-react: "npm:^0.553.0"
|
||||||
next: "npm:^16.0.10"
|
next: "npm:^16.1.5"
|
||||||
next-sitemap: "npm:^4.2.3"
|
next-sitemap: "npm:^4.2.3"
|
||||||
postcss: "npm:^8.4.33"
|
postcss: "npm:^8.4.33"
|
||||||
react: "npm:^19.2.3"
|
react: "npm:^19.2.3"
|
||||||
|
|||||||
Reference in New Issue
Block a user