Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fa9b9dda5 | ||
|
|
4bc5424815 | ||
|
|
573e2e8449 | ||
|
|
00ae942c3c | ||
|
|
9c69c7a36e | ||
|
|
9568b7d345 | ||
|
|
6bba9b6199 | ||
|
|
2f55d32485 | ||
|
|
eec37ecb31 | ||
|
|
f947ee6f22 | ||
|
|
3330d83d08 | ||
|
|
de7ed84fcf | ||
|
|
e639c72e8b | ||
|
|
43ea660d52 | ||
|
|
1f85d448e2 | ||
|
|
e7f168c373 | ||
|
|
b42698616a | ||
|
|
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.6.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,76 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [0.6.0](https://github.com/useplunk/plunk/compare/v0.5.0...v0.6.0) (2026-02-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* Ability to change workflow trigger ([eec37ec](https://github.com/useplunk/plunk/commit/eec37ecb31c63888879a1933dba4fd0c6243018b))
|
||||||
|
* Add billing for inbound ([3330d83](https://github.com/useplunk/plunk/commit/3330d83d08904bc547740bfc9fdcbc5ff214d977))
|
||||||
|
* Add billing for inbound ([de7ed84](https://github.com/useplunk/plunk/commit/de7ed84fcfddde16a9297c947048e9876712f6fa))
|
||||||
|
* Add dedicated received type ([f947ee6](https://github.com/useplunk/plunk/commit/f947ee6f22371055801fa9cfc62e15df7851986c))
|
||||||
|
* **i18n:** Add Spanish language ([9c69c7a](https://github.com/useplunk/plunk/commit/9c69c7a36edd86fdc02b8d0d3e3f8d64ed8ecfa6))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* Enhance email bounce notification with latest bounce details ([e639c72](https://github.com/useplunk/plunk/commit/e639c72e8b940ba1da472d355f930359da7c868b))
|
||||||
|
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* Add receiving emails functionality and update DNS records documentation ([b426986](https://github.com/useplunk/plunk/commit/b42698616a3f7bb079a092375ac886f2a9d7405d))
|
||||||
|
|
||||||
|
## [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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -315,6 +315,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getImportStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -322,7 +323,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getImportJobStatus(jobId);
|
const status = await QueueService.getImportJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Import job not found'});
|
return res.status(404).json({error: 'Import job not found'});
|
||||||
@@ -502,6 +503,7 @@ export class Contacts {
|
|||||||
@Middleware([requireAuth, requireEmailVerified])
|
@Middleware([requireAuth, requireEmailVerified])
|
||||||
@CatchAsync
|
@CatchAsync
|
||||||
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
public async getBulkActionStatus(req: Request, res: Response, _next: NextFunction) {
|
||||||
|
const auth = res.locals.auth;
|
||||||
const jobId = req.params.jobId;
|
const jobId = req.params.jobId;
|
||||||
|
|
||||||
if (!jobId) {
|
if (!jobId) {
|
||||||
@@ -509,7 +511,7 @@ export class Contacts {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await QueueService.getBulkActionJobStatus(jobId);
|
const status = await QueueService.getBulkActionJobStatus(jobId, auth.projectId!);
|
||||||
|
|
||||||
if (!status) {
|
if (!status) {
|
||||||
return res.status(404).json({error: 'Bulk action job not found'});
|
return res.status(404).json({error: 'Bulk action job not found'});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -330,6 +330,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,6 +350,7 @@ export class Users {
|
|||||||
billingLimitWorkflows: data.workflows,
|
billingLimitWorkflows: data.workflows,
|
||||||
billingLimitCampaigns: data.campaigns,
|
billingLimitCampaigns: data.campaigns,
|
||||||
billingLimitTransactional: data.transactional,
|
billingLimitTransactional: data.transactional,
|
||||||
|
billingLimitInbound: data.inbound,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -360,6 +362,7 @@ export class Users {
|
|||||||
workflows: project.billingLimitWorkflows,
|
workflows: project.billingLimitWorkflows,
|
||||||
campaigns: project.billingLimitCampaigns,
|
campaigns: project.billingLimitCampaigns,
|
||||||
transactional: project.billingLimitTransactional,
|
transactional: project.billingLimitTransactional,
|
||||||
|
inbound: project.billingLimitInbound,
|
||||||
},
|
},
|
||||||
data,
|
data,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Controller, Post} from '@overnightjs/core';
|
import {Controller, Post} from '@overnightjs/core';
|
||||||
import type {Prisma} from '@plunk/db';
|
import type {Prisma} from '@plunk/db';
|
||||||
import {EmailStatus} from '@plunk/db';
|
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||||
import type {Request, Response} from 'express';
|
import type {Request, Response} from 'express';
|
||||||
import signale from 'signale';
|
import signale from 'signale';
|
||||||
import type Stripe from 'stripe';
|
import type Stripe from 'stripe';
|
||||||
@@ -8,7 +8,10 @@ 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 {BillingLimitService} from '../services/BillingLimitService.js';
|
||||||
|
import {ContactService} from '../services/ContactService.js';
|
||||||
import {EventService} from '../services/EventService.js';
|
import {EventService} from '../services/EventService.js';
|
||||||
|
import {MeterService} from '../services/MeterService.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';
|
||||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||||
@@ -21,7 +24,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 +71,150 @@ 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}`);
|
||||||
|
|
||||||
|
// Check billing limits before processing inbound email
|
||||||
|
const limitCheck = await BillingLimitService.checkLimit(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||||
|
|
||||||
|
if (!limitCheck.allowed) {
|
||||||
|
signale.warn(
|
||||||
|
`[WEBHOOK] Inbound email blocked for project ${domainRecord.project.name}: ${limitCheck.message}`,
|
||||||
|
);
|
||||||
|
continue; // Skip this project but continue processing for other projects
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create an Email record for tracking (no actual email content since it's inbound)
|
||||||
|
const inboundEmail = await prisma.email.create({
|
||||||
|
data: {
|
||||||
|
projectId: domainRecord.projectId,
|
||||||
|
contactId: contact!.id,
|
||||||
|
subject: body.mail?.commonHeaders?.subject || '(No subject)',
|
||||||
|
body: '', // Inbound emails don't have body content in our system
|
||||||
|
from: recipientEmail, // The recipient address that received the email
|
||||||
|
sourceType: EmailSourceType.INBOUND,
|
||||||
|
status: EmailStatus.RECEIVED, // Inbound emails use RECEIVED status
|
||||||
|
deliveredAt: new Date(body.mail?.timestamp || new Date()),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Increment usage counter in cache
|
||||||
|
await BillingLimitService.incrementUsage(domainRecord.projectId, EmailSourceType.INBOUND);
|
||||||
|
|
||||||
|
// Record Stripe metering if project has customer
|
||||||
|
if (domainRecord.project.customer) {
|
||||||
|
await MeterService.recordEmailSent(
|
||||||
|
domainRecord.project.customer,
|
||||||
|
1, // Inbound emails count as 1 credit
|
||||||
|
`email_${inboundEmail.id}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
inboundEmail.id, // Link the event to the inbound email record
|
||||||
|
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 +300,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 +389,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,41 @@ 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_RECEIVED)) {
|
||||||
|
orConditions.push({deliveredAt: {not: null, ...dateFilter}, sourceType: 'INBOUND'});
|
||||||
|
}
|
||||||
|
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 +427,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 +462,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 +473,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 +488,23 @@ export class ActivityService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
|
if (
|
||||||
|
email.deliveredAt &&
|
||||||
|
email.sourceType === 'INBOUND' &&
|
||||||
|
(!types || types.includes(ActivityType.EMAIL_RECEIVED)) &&
|
||||||
|
isInDateRange(email.deliveredAt)
|
||||||
|
) {
|
||||||
|
activities.push({
|
||||||
|
id: `${email.id}_received`,
|
||||||
|
type: ActivityType.EMAIL_RECEIVED,
|
||||||
|
timestamp: email.deliveredAt,
|
||||||
|
contactEmail: email.contact.email,
|
||||||
|
contactId: email.contactId,
|
||||||
|
metadata: baseMetadata,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 +519,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 +534,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 +547,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;
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -183,6 +184,9 @@ export class BillingLimitService {
|
|||||||
case EmailSourceType.TRANSACTIONAL:
|
case EmailSourceType.TRANSACTIONAL:
|
||||||
limit = project.billingLimitTransactional;
|
limit = project.billingLimitTransactional;
|
||||||
break;
|
break;
|
||||||
|
case EmailSourceType.INBOUND:
|
||||||
|
limit = project.billingLimitInbound;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
limit = null;
|
limit = null;
|
||||||
}
|
}
|
||||||
@@ -191,7 +195,8 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
// Free tier projects (no subscription and no custom limits): enforce total 1000 email/month limit
|
||||||
// Only enforce free tier limits if billing is enabled
|
// Only enforce free tier limits if billing is enabled
|
||||||
@@ -368,6 +373,7 @@ export class BillingLimitService {
|
|||||||
billingLimitWorkflows: true,
|
billingLimitWorkflows: true,
|
||||||
billingLimitCampaigns: true,
|
billingLimitCampaigns: true,
|
||||||
billingLimitTransactional: true,
|
billingLimitTransactional: true,
|
||||||
|
billingLimitInbound: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -390,10 +396,11 @@ export class BillingLimitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get usage for all categories in parallel
|
// Get usage for all categories in parallel
|
||||||
const [workflowUsage, campaignUsage, transactionalUsage] = await Promise.all([
|
const [workflowUsage, campaignUsage, transactionalUsage, inboundUsage] = await Promise.all([
|
||||||
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
this.getUsage(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
this.getUsage(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getUsage(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getUsage(projectId, EmailSourceType.INBOUND),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Helper to calculate category usage
|
// Helper to calculate category usage
|
||||||
@@ -412,18 +419,19 @@ export class BillingLimitService {
|
|||||||
const hasCustomLimits =
|
const hasCustomLimits =
|
||||||
project.billingLimitWorkflows !== null ||
|
project.billingLimitWorkflows !== null ||
|
||||||
project.billingLimitCampaigns !== null ||
|
project.billingLimitCampaigns !== null ||
|
||||||
project.billingLimitTransactional !== null;
|
project.billingLimitTransactional !== null ||
|
||||||
|
project.billingLimitInbound !== null;
|
||||||
|
|
||||||
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
// Free tier projects (no subscription and no custom limits): show total usage with shared limit
|
||||||
// Only show free tier limits if billing is enabled
|
// Only show free tier limits if billing is enabled
|
||||||
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
if (STRIPE_ENABLED && !project.subscription && !hasCustomLimits) {
|
||||||
const totalUsage = workflowUsage + campaignUsage + transactionalUsage;
|
const totalUsage = workflowUsage + campaignUsage + transactionalUsage + inboundUsage;
|
||||||
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
const limit = this.FREE_TIER_TOTAL_LIMIT;
|
||||||
const percentage = (totalUsage / limit) * 100;
|
const percentage = (totalUsage / limit) * 100;
|
||||||
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
const isWarning = percentage >= this.WARNING_THRESHOLD * 100;
|
||||||
const isBlocked = totalUsage >= limit;
|
const isBlocked = totalUsage >= limit;
|
||||||
|
|
||||||
// For free tier, show the same limit and total usage for all three categories
|
// For free tier, show the same limit and total usage for all four categories
|
||||||
// This makes it clear in the UI that it's a shared limit
|
// This makes it clear in the UI that it's a shared limit
|
||||||
const sharedUsageInfo: CategoryUsage = {
|
const sharedUsageInfo: CategoryUsage = {
|
||||||
limit,
|
limit,
|
||||||
@@ -437,6 +445,7 @@ export class BillingLimitService {
|
|||||||
workflows: sharedUsageInfo,
|
workflows: sharedUsageInfo,
|
||||||
campaigns: sharedUsageInfo,
|
campaigns: sharedUsageInfo,
|
||||||
transactional: sharedUsageInfo,
|
transactional: sharedUsageInfo,
|
||||||
|
inbound: sharedUsageInfo,
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -446,6 +455,7 @@ export class BillingLimitService {
|
|||||||
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
workflows: calculateCategoryUsage(workflowUsage, project.billingLimitWorkflows),
|
||||||
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
campaigns: calculateCategoryUsage(campaignUsage, project.billingLimitCampaigns),
|
||||||
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
transactional: calculateCategoryUsage(transactionalUsage, project.billingLimitTransactional),
|
||||||
|
inbound: calculateCategoryUsage(inboundUsage, project.billingLimitInbound),
|
||||||
currency,
|
currency,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -470,6 +480,7 @@ export class BillingLimitService {
|
|||||||
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
this.getCacheKey(projectId, EmailSourceType.WORKFLOW),
|
||||||
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
this.getCacheKey(projectId, EmailSourceType.CAMPAIGN),
|
||||||
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
this.getCacheKey(projectId, EmailSourceType.TRANSACTIONAL),
|
||||||
|
this.getCacheKey(projectId, EmailSourceType.INBOUND),
|
||||||
];
|
];
|
||||||
|
|
||||||
await Promise.all(keys.map(key => redis.del(key)));
|
await Promise.all(keys.map(key => redis.del(key)));
|
||||||
@@ -493,11 +504,13 @@ export class BillingLimitService {
|
|||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
newLimits: {
|
newLimits: {
|
||||||
workflows: number | null;
|
workflows: number | null;
|
||||||
campaigns: number | null;
|
campaigns: number | null;
|
||||||
transactional: number | null;
|
transactional: number | null;
|
||||||
|
inbound: number | null;
|
||||||
},
|
},
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@@ -531,6 +544,14 @@ export class BillingLimitService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check inbound limit
|
||||||
|
if (oldLimits.inbound !== newLimits.inbound) {
|
||||||
|
keysToDelete.push(
|
||||||
|
Keys.Billing.warningEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||||
|
Keys.Billing.limitEmail(projectId, EmailSourceType.INBOUND, year, month),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (keysToDelete.length > 0) {
|
if (keysToDelete.length > 0) {
|
||||||
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
await Promise.all(keysToDelete.map(key => redis.del(key)));
|
||||||
signale.debug(
|
signale.debug(
|
||||||
@@ -580,8 +601,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 +631,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 +653,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 +682,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,
|
||||||
});
|
});
|
||||||
@@ -568,10 +585,11 @@ export class EmailService {
|
|||||||
: {}),
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [total, sent, delivered, opened, clicked, bounced, failed] = await Promise.all([
|
const [total, sent, delivered, received, opened, clicked, bounced, failed] = await Promise.all([
|
||||||
prisma.email.count({where}),
|
prisma.email.count({where}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
prisma.email.count({where: {...where, status: EmailStatus.SENT}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.DELIVERED}}),
|
||||||
|
prisma.email.count({where: {...where, status: EmailStatus.RECEIVED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.OPENED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.CLICKED}}),
|
||||||
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
prisma.email.count({where: {...where, status: EmailStatus.BOUNCED}}),
|
||||||
@@ -582,6 +600,7 @@ export class EmailService {
|
|||||||
total,
|
total,
|
||||||
sent,
|
sent,
|
||||||
delivered,
|
delivered,
|
||||||
|
received,
|
||||||
opened,
|
opened,
|
||||||
clicked,
|
clicked,
|
||||||
bounced,
|
bounced,
|
||||||
|
|||||||
@@ -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,14 +225,11 @@ 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');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notify about email bounce - LOW priority (high volume)
|
* Notify about email bounce - LOW priority (high volume)
|
||||||
* Rate-limited to only send notification every 20 bounces with per-project breakdown
|
* Rate-limited to only send notification every 20 bounces with latest 20 bounce details
|
||||||
*/
|
*/
|
||||||
public static async notifyEmailBounce(
|
public static async notifyEmailBounce(
|
||||||
projectName: string,
|
projectName: string,
|
||||||
@@ -241,63 +240,51 @@ export class NtfyService {
|
|||||||
// Import redis at runtime to avoid circular dependencies
|
// Import redis at runtime to avoid circular dependencies
|
||||||
const {redis} = await import('../database/redis.js');
|
const {redis} = await import('../database/redis.js');
|
||||||
|
|
||||||
// Use Redis counters to track bounce count globally and per-project
|
// Use Redis counters to track bounce count globally
|
||||||
const globalCountKey = `ntfy:bounce:count`;
|
const globalCountKey = `ntfy:bounce:count`;
|
||||||
const projectCountKey = `ntfy:bounce:count:${projectId}`;
|
const bounceListKey = `ntfy:bounce:latest`;
|
||||||
const projectListKey = `ntfy:bounce:projects`;
|
|
||||||
|
|
||||||
// Increment global counter
|
// Increment global counter
|
||||||
const globalCount = await redis.incr(globalCountKey);
|
const globalCount = await redis.incr(globalCountKey);
|
||||||
|
|
||||||
// Increment project-specific counter
|
// Store this bounce event in a list (keep latest 20)
|
||||||
await redis.incr(projectCountKey);
|
const bounceEvent = JSON.stringify({
|
||||||
|
projectName,
|
||||||
// Add project to the set of projects with bounces (for tracking)
|
projectId,
|
||||||
await redis.sadd(projectListKey, projectId);
|
recipientEmail,
|
||||||
|
bounceType: bounceType || 'Unknown',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
await redis.lpush(bounceListKey, bounceEvent);
|
||||||
|
await redis.ltrim(bounceListKey, 0, 19); // Keep only latest 20
|
||||||
|
|
||||||
// Set expiry on first increment (24 hour rolling window)
|
// Set expiry on first increment (24 hour rolling window)
|
||||||
if (globalCount === 1) {
|
if (globalCount === 1) {
|
||||||
await redis.expire(globalCountKey, 86400);
|
await redis.expire(globalCountKey, 86400);
|
||||||
await redis.expire(projectListKey, 86400);
|
await redis.expire(bounceListKey, 86400);
|
||||||
}
|
}
|
||||||
// Always refresh project counter expiry to match global window
|
|
||||||
await redis.expire(projectCountKey, 86400);
|
|
||||||
|
|
||||||
// Only send notification every 20 bounces
|
// Only send notification every 20 bounces
|
||||||
if (globalCount % 20 === 0) {
|
if (globalCount % 20 === 0) {
|
||||||
// Get all projects with bounces
|
// Get the latest 20 bounces
|
||||||
const projectIds = await redis.smembers(projectListKey);
|
const latestBounces = await redis.lrange(bounceListKey, 0, 19);
|
||||||
|
|
||||||
// Get bounce counts for each project
|
// Parse and format the bounce events
|
||||||
const projectCounts: Array<{projectId: string; count: number; name: string}> = [];
|
const bounceDetails = latestBounces
|
||||||
for (const pid of projectIds) {
|
.map(bounce => {
|
||||||
const count = await redis.get(`ntfy:bounce:count:${pid}`);
|
try {
|
||||||
if (count) {
|
const parsed = JSON.parse(bounce);
|
||||||
// Fetch project name from database
|
return ` • ${parsed.recipientEmail} (${parsed.bounceType}) - ${parsed.projectName}`;
|
||||||
const {prisma} = await import('../database/prisma.js');
|
} catch {
|
||||||
const project = await prisma.project.findUnique({
|
return null;
|
||||||
where: {id: pid},
|
}
|
||||||
select: {name: true},
|
})
|
||||||
});
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
projectCounts.push({
|
|
||||||
projectId: pid,
|
|
||||||
count: parseInt(count, 10),
|
|
||||||
name: project?.name || 'Unknown',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by count descending
|
|
||||||
projectCounts.sort((a, b) => b.count - a.count);
|
|
||||||
|
|
||||||
// Build breakdown message
|
|
||||||
const breakdown = projectCounts.map(p => ` • ${p.name} (${p.projectId}): ${p.count}`).join('\n');
|
|
||||||
|
|
||||||
const bounceInfo = bounceType ? ` (${bounceType})` : '';
|
|
||||||
await this.send({
|
await this.send({
|
||||||
title: 'Email Bounces',
|
title: 'Email Bounces',
|
||||||
message: `20 email bounces detected (total: ${globalCount})\n\nBreakdown by project:\n${breakdown}\n\nLatest: ${recipientEmail}${bounceInfo} in "${projectName}"`,
|
message: `20 email bounces detected (total: ${globalCount})\n\nLatest 20 bounces:\n${bounceDetails}`,
|
||||||
priority: NtfyPriority.LOW,
|
priority: NtfyPriority.LOW,
|
||||||
tags: [NtfyTag.WARNING],
|
tags: [NtfyTag.WARNING],
|
||||||
});
|
});
|
||||||
@@ -606,9 +593,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 +606,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 +624,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 +637,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 +750,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
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -282,14 +282,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get import job status and progress
|
* Get import job status and progress
|
||||||
|
* @param jobId - The job ID
|
||||||
|
* @param projectId - The project ID to verify authorization
|
||||||
|
* @returns Job status or null if not found or unauthorized
|
||||||
*/
|
*/
|
||||||
public static async getImportJobStatus(jobId: string) {
|
public static async getImportJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await importQueue.getJob(jobId);
|
const job = await importQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Verify that the job belongs to the requesting project
|
||||||
|
if (job.data.projectId !== projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
@@ -324,14 +332,22 @@ export class QueueService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get bulk action job status and progress
|
* Get bulk action job status and progress
|
||||||
|
* @param jobId - The job ID
|
||||||
|
* @param projectId - The project ID to verify authorization
|
||||||
|
* @returns Job status or null if not found or unauthorized
|
||||||
*/
|
*/
|
||||||
public static async getBulkActionJobStatus(jobId: string) {
|
public static async getBulkActionJobStatus(jobId: string, projectId: string) {
|
||||||
const job = await bulkContactQueue.getJob(jobId);
|
const job = await bulkContactQueue.getJob(jobId);
|
||||||
|
|
||||||
if (!job) {
|
if (!job) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Verify that the job belongs to the requesting project
|
||||||
|
if (job.data.projectId !== projectId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const state = await job.getState();
|
const state = await job.getState();
|
||||||
const progress = job.progress;
|
const progress = job.progress;
|
||||||
const returnValue = job.returnvalue;
|
const returnValue = job.returnvalue;
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -194,25 +194,57 @@ export class WorkflowService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateData: Prisma.WorkflowUpdateInput = {};
|
// Use transaction to update workflow and TRIGGER step atomically
|
||||||
|
const updated = await prisma.$transaction(async tx => {
|
||||||
|
const updateData: Prisma.WorkflowUpdateInput = {};
|
||||||
|
|
||||||
if (data.name !== undefined) updateData.name = data.name;
|
if (data.name !== undefined) updateData.name = data.name;
|
||||||
if (data.description !== undefined) updateData.description = data.description;
|
if (data.description !== undefined) updateData.description = data.description;
|
||||||
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
if (data.triggerType !== undefined) updateData.triggerType = data.triggerType;
|
||||||
if (data.triggerConfig !== undefined) {
|
if (data.triggerConfig !== undefined) {
|
||||||
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
updateData.triggerConfig = data.triggerConfig === null ? Prisma.JsonNull : data.triggerConfig;
|
||||||
}
|
}
|
||||||
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
if (data.enabled !== undefined) updateData.enabled = data.enabled;
|
||||||
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
if (data.allowReentry !== undefined) updateData.allowReentry = data.allowReentry;
|
||||||
|
|
||||||
const updated = await prisma.workflow.update({
|
const updatedWorkflow = await tx.workflow.update({
|
||||||
where: {id: workflowId},
|
where: {id: workflowId},
|
||||||
data: updateData,
|
data: updateData,
|
||||||
include: {
|
include: {
|
||||||
project: {
|
project: {
|
||||||
select: {name: true},
|
select: {name: true},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
|
|
||||||
|
// If triggerConfig changed and it's an EVENT trigger, update the TRIGGER step
|
||||||
|
if (data.triggerConfig !== undefined && updatedWorkflow.triggerType === 'EVENT') {
|
||||||
|
const newTriggerConfig = data.triggerConfig as {eventName?: string} | null;
|
||||||
|
const eventName = newTriggerConfig?.eventName;
|
||||||
|
|
||||||
|
if (eventName) {
|
||||||
|
// Find the TRIGGER step
|
||||||
|
const triggerStep = await tx.workflowStep.findFirst({
|
||||||
|
where: {
|
||||||
|
workflowId: workflowId,
|
||||||
|
type: 'TRIGGER',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (triggerStep) {
|
||||||
|
// Update TRIGGER step config and name to match
|
||||||
|
await tx.workflowStep.update({
|
||||||
|
where: {id: triggerStep.id},
|
||||||
|
data: {
|
||||||
|
name: `Trigger: ${eventName}`,
|
||||||
|
config: {eventName},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedWorkflow;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
// Invalidate workflow cache if enabled status changed or workflow is enabled
|
||||||
@@ -220,6 +252,11 @@ export class WorkflowService {
|
|||||||
await EventService.invalidateWorkflowCache(projectId);
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Also invalidate cache if triggerConfig changed on an enabled workflow
|
||||||
|
if (data.triggerConfig !== undefined && updated.enabled) {
|
||||||
|
await EventService.invalidateWorkflowCache(projectId);
|
||||||
|
}
|
||||||
|
|
||||||
// Send notification if enabled status changed
|
// Send notification if enabled status changed
|
||||||
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
if (data.enabled !== undefined && data.enabled !== workflow.enabled) {
|
||||||
if (data.enabled) {
|
if (data.enabled) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ export default function Footer() {
|
|||||||
Terms
|
Terms
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href={'/dpa'} className="text-sm text-neutral-600 transition hover:text-neutral-900">
|
||||||
|
DPA
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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'});
|
||||||
|
|||||||
@@ -0,0 +1,695 @@
|
|||||||
|
import {NextSeo} from 'next-seo';
|
||||||
|
import React from 'react';
|
||||||
|
import {Navbar} from '../components';
|
||||||
|
|
||||||
|
export default function DataProcessingAgreement() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NextSeo
|
||||||
|
title="Plunk Data Processing Agreement (DPA) | GDPR Compliance"
|
||||||
|
description="Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights."
|
||||||
|
openGraph={{
|
||||||
|
title: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
description:
|
||||||
|
'Data Processing Agreement for Plunk customers. GDPR-compliant DPA covering data processing activities, sub-processors, security measures, and data subject rights.',
|
||||||
|
}}
|
||||||
|
additionalMetaTags={[
|
||||||
|
{
|
||||||
|
property: 'title',
|
||||||
|
content: 'Plunk Data Processing Agreement (DPA) | GDPR Compliance',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="min-h-screen bg-white">
|
||||||
|
<div className="px-8 pb-32 sm:px-12 md:px-16 lg:px-20 xl:px-24 2xl:px-56">
|
||||||
|
<Navbar />
|
||||||
|
|
||||||
|
<div className="mx-auto max-w-4xl py-16">
|
||||||
|
{/* Hero */}
|
||||||
|
<div className="mb-12">
|
||||||
|
<h1 className="text-4xl font-bold text-neutral-900">Data Processing Agreement</h1>
|
||||||
|
<p className="mt-2 text-sm text-neutral-600">Last Updated: February 18, 2026</p>
|
||||||
|
<p className="mt-4 text-base leading-relaxed text-neutral-700">
|
||||||
|
This Data Processing Agreement ("DPA") forms part of the Terms of Service between you ("Customer", "Data
|
||||||
|
Controller") and Plunk ("Processor", "we", "us") and governs the processing of Personal Data in
|
||||||
|
accordance with GDPR requirements.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<p className="text-sm leading-relaxed text-blue-900">
|
||||||
|
<strong className="font-semibold">GDPR Requirement:</strong> This DPA is required under Article 28 of
|
||||||
|
the GDPR. By using Plunk's hosted service, you accept and agree to the terms of this DPA.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table of Contents */}
|
||||||
|
<nav className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-lg font-semibold text-neutral-900">Contents</h2>
|
||||||
|
<ol className="space-y-2 text-sm text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<a href="#definitions" className="text-blue-600 hover:underline">
|
||||||
|
1. Definitions
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#scope" className="text-blue-600 hover:underline">
|
||||||
|
2. Scope & Applicability
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#processing-details" className="text-blue-600 hover:underline">
|
||||||
|
3. Processing Details
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#obligations" className="text-blue-600 hover:underline">
|
||||||
|
4. Data Processor Obligations
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#security" className="text-blue-600 hover:underline">
|
||||||
|
5. Security Measures
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#sub-processors" className="text-blue-600 hover:underline">
|
||||||
|
6. Sub-Processors
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-subject-rights" className="text-blue-600 hover:underline">
|
||||||
|
7. Data Subject Rights
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#data-breach" className="text-blue-600 hover:underline">
|
||||||
|
8. Data Breach Notification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#international-transfers" className="text-blue-600 hover:underline">
|
||||||
|
9. International Data Transfers
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#audits" className="text-blue-600 hover:underline">
|
||||||
|
10. Audits & Compliance
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#deletion" className="text-blue-600 hover:underline">
|
||||||
|
11. Data Deletion & Return
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#liability" className="text-blue-600 hover:underline">
|
||||||
|
12. Liability & Indemnification
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a href="#term" className="text-blue-600 hover:underline">
|
||||||
|
13. Term & Termination
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
{/* Content Sections */}
|
||||||
|
<div className="space-y-12">
|
||||||
|
{/* Section 1 */}
|
||||||
|
<section id="definitions" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">1. Definitions</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Terms used in this DPA have the meanings set forth in the GDPR. Specifically:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Personal Data"</strong> means any information
|
||||||
|
relating to an identified or identifiable natural person processed via Plunk
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Controller"</strong> means the Customer who
|
||||||
|
determines the purposes and means of processing Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Processor"</strong> means Plunk, which
|
||||||
|
processes Personal Data on behalf of the Data Controller
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Sub-processor"</strong> means any third party
|
||||||
|
engaged by Plunk to process Personal Data
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"Data Subject"</strong> means the individuals
|
||||||
|
whose Personal Data is processed (your contacts/subscribers)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">"GDPR"</strong> means Regulation (EU) 2016/679
|
||||||
|
(General Data Protection Regulation)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 2 */}
|
||||||
|
<section id="scope" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">2. Scope & Applicability</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Hosted Service Only</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA applies ONLY to customers using Plunk's hosted service (useplunk.com). Self-hosted
|
||||||
|
deployments are NOT covered by this DPA - you are solely responsible for GDPR compliance when
|
||||||
|
self-hosting.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Agreement Hierarchy</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA supplements the Plunk Terms of Service and Privacy Policy. In case of conflict regarding data
|
||||||
|
processing:
|
||||||
|
</p>
|
||||||
|
<ol className="mb-4 ml-6 list-decimal space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA takes precedence</li>
|
||||||
|
<li>Then the Terms of Service</li>
|
||||||
|
<li>Then the Privacy Policy</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Acceptance</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
By using Plunk's hosted service, you acknowledge that you have read, understood, and agree to be bound
|
||||||
|
by this DPA. This constitutes a legally binding agreement between Customer and Plunk.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 3 */}
|
||||||
|
<section id="processing-details" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">3. Processing Details</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 28(3), the following details describe the processing activities:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Subject Matter</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing of Personal Data necessary to provide email automation, transactional email, marketing
|
||||||
|
campaigns, and workflow automation services.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Duration</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Processing occurs for the duration of your Plunk subscription/account, plus 30 days for API logs
|
||||||
|
(which are automatically deleted), and until you request account deletion.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Nature & Purpose</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Storing and managing contact databases</li>
|
||||||
|
<li>Sending transactional, marketing, and workflow-triggered emails</li>
|
||||||
|
<li>Tracking email delivery, opens, clicks, bounces, and complaints (per your settings)</li>
|
||||||
|
<li>Managing email templates and automation workflows</li>
|
||||||
|
<li>Processing email events and webhooks</li>
|
||||||
|
<li>Providing analytics and reporting</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Type of Personal Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email addresses (required)</li>
|
||||||
|
<li>Names and custom contact fields (optional, Customer-defined)</li>
|
||||||
|
<li>Email content (subject lines, message bodies, attachments)</li>
|
||||||
|
<li>Subscription status and preferences</li>
|
||||||
|
<li>Email activity data (opens, clicks, bounces, complaints)</li>
|
||||||
|
<li>Timestamps and metadata</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Categories of Data Subjects</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer's email subscribers and contacts</li>
|
||||||
|
<li>Recipients of transactional emails</li>
|
||||||
|
<li>Marketing campaign recipients</li>
|
||||||
|
<li>Workflow automation recipients</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 4 */}
|
||||||
|
<section id="obligations" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">4. Data Processor Obligations</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk commits to the following obligations as Data Processor:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Processing Instructions</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Process Personal Data only on documented instructions from Customer (via API, dashboard, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Not process Personal Data for any other purpose without Customer's prior written consent</li>
|
||||||
|
<li>Immediately inform Customer if instructions violate GDPR or other EU data protection laws</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Confidentiality</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Ensure persons authorized to process Personal Data are bound by confidentiality obligations</li>
|
||||||
|
<li>Maintain confidentiality of all Personal Data processed via Plunk</li>
|
||||||
|
<li>
|
||||||
|
Not disclose Personal Data to third parties except as required by law or with Customer consent
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Cooperation</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Assist Customer in responding to Data Subject rights requests (see Section 7)</li>
|
||||||
|
<li>
|
||||||
|
Assist Customer in ensuring compliance with GDPR security, breach notification, and impact
|
||||||
|
assessment obligations
|
||||||
|
</li>
|
||||||
|
<li>Provide information necessary to demonstrate compliance with Article 28</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 5 */}
|
||||||
|
<section id="security" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">5. Security Measures</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
As required by GDPR Article 32, Plunk implements appropriate technical and organizational measures to
|
||||||
|
ensure a level of security appropriate to the risk:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Technical Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Industry-standard password hashing (never stored in plaintext)</li>
|
||||||
|
<li>HTTPS-only API access (TLS 1.2+)</li>
|
||||||
|
<li>Database connections encrypted via TLS/SSL</li>
|
||||||
|
<li>HttpOnly, secure cookies with appropriate SameSite settings</li>
|
||||||
|
<li>Authentication tokens with limited expiration periods</li>
|
||||||
|
<li>Rate limiting to prevent brute force attacks</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Organizational Safeguards</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Access controls limiting personnel access to Personal Data</li>
|
||||||
|
<li>Automated bounce and complaint rate monitoring</li>
|
||||||
|
<li>Regular security updates and patching</li>
|
||||||
|
<li>Data segregation by project (multi-tenancy with isolation)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data Residency</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Primary data storage: EU/EEA (Hetzner infrastructure)</li>
|
||||||
|
<li>Database and file storage remain within EU/EEA</li>
|
||||||
|
<li>Email delivery may transit non-EU regions (see Section 9)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 6 */}
|
||||||
|
<section id="sub-processors" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">6. Sub-Processors</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer authorizes Plunk to engage the following sub-processors to process Personal Data:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mb-6 overflow-x-auto">
|
||||||
|
<table className="min-w-full border border-neutral-300">
|
||||||
|
<thead className="bg-neutral-50">
|
||||||
|
<tr>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Sub-Processor
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Service
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Location
|
||||||
|
</th>
|
||||||
|
<th className="border border-neutral-300 px-4 py-2 text-left text-sm font-semibold text-neutral-900">
|
||||||
|
Purpose
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="bg-white">
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Amazon Web Services (AWS SES)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Email Delivery</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Global (data in transit only)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Sending emails to recipients
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">Stripe, Inc.</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Payment Processing
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
USA (PCI-DSS compliant)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Billing for paid accounts
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Hetzner Online GmbH
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Infrastructure Hosting
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
EU/EEA (Germany)
|
||||||
|
</td>
|
||||||
|
<td className="border border-neutral-300 px-4 py-2 text-sm text-neutral-700">
|
||||||
|
Database and application hosting
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Sub-Processor Obligations</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All sub-processors are bound by data protection obligations equivalent to this DPA</li>
|
||||||
|
<li>Plunk remains fully liable to Customer for sub-processor performance</li>
|
||||||
|
<li>Sub-processors have executed Data Processing Agreements with Plunk</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Changes to Sub-Processors</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk will provide 30 days advance notice of new or replacement sub-processors via email</li>
|
||||||
|
<li>Notice will be sent to the email address associated with your account</li>
|
||||||
|
<li>
|
||||||
|
If you object on reasonable grounds related to data protection, you may terminate your account
|
||||||
|
within 30 days
|
||||||
|
</li>
|
||||||
|
<li>Updated sub-processor list will be maintained on this page (check "Last Updated" date)</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 7 */}
|
||||||
|
<section id="data-subject-rights" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">7. Data Subject Rights</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will assist Customer in fulfilling Data Subject rights requests under GDPR Articles 15-22:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Self-Service via API</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer can fulfill most Data Subject requests independently via API:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Access (Art. 15):</strong> Retrieve contact data
|
||||||
|
via GET /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Rectification (Art. 16):</strong> Update contact
|
||||||
|
data via PATCH /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Erasure (Art. 17):</strong> Delete contact via
|
||||||
|
DELETE /contacts/:id
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Restriction (Art. 18):</strong> Update
|
||||||
|
subscription status to "unsubscribed"
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="font-semibold text-neutral-900">Portability (Art. 20):</strong> Use API to access
|
||||||
|
data in JSON format
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Assistance from Plunk</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
If Customer cannot fulfill a request via API, contact [email protected]:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Provide Data Subject's email address and nature of request</li>
|
||||||
|
<li>Plunk will respond within 10 business days with requested information or assistance</li>
|
||||||
|
<li>
|
||||||
|
Customer remains responsible for verifying Data Subject identity before disclosing information
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Email Activity Data</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Email opens, clicks, bounces, and complaints are linked to contact records</li>
|
||||||
|
<li>Deleting a contact will cascade delete associated email activity</li>
|
||||||
|
<li>API logs (non-Personal Data) are automatically deleted after 30 days</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 8 */}
|
||||||
|
<section id="data-breach" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">8. Data Breach Notification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Notification Obligation</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In the event of a Personal Data breach affecting Customer data, Plunk will:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Notify Customer without undue delay and within 72 hours of becoming aware of the breach (per GDPR
|
||||||
|
Art. 33)
|
||||||
|
</li>
|
||||||
|
<li>Send notification to the primary email address associated with Customer's account</li>
|
||||||
|
<li>Provide information to enable Customer to meet any GDPR breach reporting obligations</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Breach Information</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Notifications will include (to the extent known):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Nature of the breach (what happened)</li>
|
||||||
|
<li>Categories and approximate number of Data Subjects affected</li>
|
||||||
|
<li>Categories and approximate number of Personal Data records affected</li>
|
||||||
|
<li>Likely consequences of the breach</li>
|
||||||
|
<li>Measures taken or proposed to address the breach and mitigate harm</li>
|
||||||
|
<li>Contact point for more information ([email protected])</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibility</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer is responsible for notifying their supervisory authority if required by GDPR Art. 33</li>
|
||||||
|
<li>Customer is responsible for notifying affected Data Subjects if required by GDPR Art. 34</li>
|
||||||
|
<li>Plunk's breach notification to Customer does NOT constitute legal or compliance advice</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 9 */}
|
||||||
|
<section id="international-transfers" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">9. International Data Transfers</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Primary Storage (EU/EEA)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All contact data, templates, workflows, and account data stored in EU/EEA (Hetzner, Germany)</li>
|
||||||
|
<li>No routine transfers of stored Personal Data outside EU/EEA</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Data in Transit (Email Delivery)</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When emails are sent to recipients, Personal Data may transit through non-EU regions:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>AWS SES processes emails globally for delivery purposes</li>
|
||||||
|
<li>Data is in transit only (not stored long-term outside EU/EEA)</li>
|
||||||
|
<li>Protected by AWS Data Processing Agreement and Standard Contractual Clauses (SCCs)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Payment Data (Stripe)</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Payment information (credit cards) processed by Stripe (USA-based)</li>
|
||||||
|
<li>Stripe is PCI-DSS Level 1 certified</li>
|
||||||
|
<li>Protected by Stripe's Data Processing Agreement and Standard Contractual Clauses</li>
|
||||||
|
<li>Plunk does NOT store credit card numbers (tokenized by Stripe)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Standard Contractual Clauses</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
All sub-processors that process Personal Data outside EU/EEA have executed Standard Contractual
|
||||||
|
Clauses (SCCs) approved by the European Commission, providing appropriate safeguards for international
|
||||||
|
transfers per GDPR Article 46.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 10 */}
|
||||||
|
<section id="audits" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">10. Audits & Compliance</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Information Provision</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk will make available to Customer information necessary to demonstrate compliance with GDPR
|
||||||
|
Article 28 obligations, including:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>This DPA (publicly available)</li>
|
||||||
|
<li>Privacy Policy describing processing activities</li>
|
||||||
|
<li>Sub-processor list (Section 6 above)</li>
|
||||||
|
<li>Security measures documentation (available upon reasonable request)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Audit Rights</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Customer may audit Plunk's compliance with this DPA, subject to the following:
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Audits limited to once per year unless required by supervisory authority</li>
|
||||||
|
<li>Request must be submitted in writing to legal@useplunk.com with 30 days notice</li>
|
||||||
|
<li>Audits must be conducted by independent third-party auditors bound by confidentiality</li>
|
||||||
|
<li>Audits conducted during business hours with minimal disruption to operations</li>
|
||||||
|
<li>Customer bears all costs of audit</li>
|
||||||
|
<li>Audit scope limited to GDPR compliance (not general security assessments)</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Alternative to Audits</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
In lieu of conducting a full audit, Customer may request and review sub-processor certifications and
|
||||||
|
compliance documentation (SOC 2, ISO 27001, etc.) where available.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 11 */}
|
||||||
|
<section id="deletion" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">11. Data Deletion & Return</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Account Deletion</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
When Customer deletes their Plunk account (via dashboard or by request):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>All Personal Data is immediately deleted from production systems</li>
|
||||||
|
<li>No grace period or recovery window (deletion is permanent)</li>
|
||||||
|
<li>
|
||||||
|
Customer should access or backup data via API before deletion (Plunk does NOT provide data export)
|
||||||
|
</li>
|
||||||
|
<li>Deletion includes: contacts, emails, templates, workflows, campaigns, and email activity</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Backup Retention</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Deleted data may remain in encrypted backups for up to 30 days for disaster recovery purposes</li>
|
||||||
|
<li>Backup data is not accessible or restorable after account deletion</li>
|
||||||
|
<li>Backups are automatically overwritten after retention period</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Legal Retention</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk may retain certain data if required by law (e.g., accounting records, fraud prevention):
|
||||||
|
</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Billing records: Retained per tax/accounting laws (typically 7 years)</li>
|
||||||
|
<li>Fraud/abuse records: Retained for security purposes (email addresses of suspended accounts)</li>
|
||||||
|
<li>Legal requests: Data subject to legal holds retained as required by law</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">No Data Return</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Plunk does NOT provide data export or return upon termination. Customer must retrieve data via API
|
||||||
|
before deleting account. Once deleted, data cannot be recovered.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 12 */}
|
||||||
|
<section id="liability" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">12. Liability & Indemnification</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Customer Responsibilities</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">Customer (Data Controller) is responsible for:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Ensuring lawful basis for processing under GDPR (consent, contract, legitimate interest, etc.)
|
||||||
|
</li>
|
||||||
|
<li>Obtaining necessary consents from Data Subjects before adding them to Plunk</li>
|
||||||
|
<li>Providing privacy notices to Data Subjects per GDPR Article 13/14</li>
|
||||||
|
<li>Complying with anti-spam laws (CAN-SPAM, CASL, GDPR, etc.)</li>
|
||||||
|
<li>Verifying Data Subject identity before fulfilling rights requests</li>
|
||||||
|
<li>Notifying supervisory authorities and Data Subjects of breaches where required</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Plunk's Liability</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk is liable to Customer for compliance with this DPA and GDPR Article 28 obligations</li>
|
||||||
|
<li>Plunk is liable for sub-processor acts/omissions to the same extent as its own acts</li>
|
||||||
|
<li>
|
||||||
|
Liability limitations in Terms of Service apply, except where prohibited by GDPR (particularly Art.
|
||||||
|
82)
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Indemnification</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>
|
||||||
|
Customer indemnifies Plunk for claims arising from Customer's violation of GDPR or data protection
|
||||||
|
laws
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Plunk indemnifies Customer for claims arising solely from Plunk's breach of this DPA or GDPR Article
|
||||||
|
28
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">GDPR Fines</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Under GDPR Article 82(3), liability for damages is allocated between Controller and Processor based on
|
||||||
|
fault. Each party is liable only for the damage caused by its own GDPR violation.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Section 13 */}
|
||||||
|
<section id="term" className="mb-12">
|
||||||
|
<h2 className="mb-4 text-2xl font-bold text-neutral-900">13. Term & Termination</h2>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Effective Date</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
This DPA is effective as of the date you first use Plunk's hosted service and remains in effect for
|
||||||
|
the duration of the Terms of Service.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Termination</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">This DPA terminates automatically when:</p>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Customer deletes their Plunk account</li>
|
||||||
|
<li>Terms of Service are terminated</li>
|
||||||
|
<li>All Personal Data has been deleted per Section 11</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">Survival</h3>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
Obligations regarding confidentiality, data deletion, and liability survive termination to the extent
|
||||||
|
necessary to fulfill their purpose.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h3 className="mb-3 mt-6 text-xl font-semibold text-neutral-900">DPA Updates</h3>
|
||||||
|
<ul className="mb-4 ml-6 list-disc space-y-2 text-neutral-700">
|
||||||
|
<li>Plunk may update this DPA to reflect changes in law, sub-processors, or processing activities</li>
|
||||||
|
<li>Material changes will be notified via email 30 days in advance</li>
|
||||||
|
<li>Continued use of Plunk after changes constitutes acceptance</li>
|
||||||
|
<li>Check "Last Updated" date at top of page for version tracking</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Contact Section */}
|
||||||
|
<section className="mb-12 rounded-lg border border-neutral-200 bg-neutral-50 p-6">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-neutral-900">Questions or Requests?</h2>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
For questions about this DPA, data processing activities, or to exercise audit rights:
|
||||||
|
</p>
|
||||||
|
<p className="mb-2 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Email:</strong> legal@useplunk.com
|
||||||
|
</p>
|
||||||
|
<p className="mb-4 leading-relaxed text-neutral-700">
|
||||||
|
<strong className="font-semibold text-neutral-900">Response Time:</strong> Within 10 business days
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
For Data Subject rights requests, Customers should use the API (see Section 7) or contact
|
||||||
|
legal@useplunk.com with Data Subject's email address and nature of request.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1149
-8766
File diff suppressed because it is too large
Load Diff
+681
-4090
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Eye,
|
Eye,
|
||||||
|
Inbox,
|
||||||
MousePointerClick,
|
MousePointerClick,
|
||||||
Send,
|
Send,
|
||||||
|
ShieldAlert,
|
||||||
Workflow,
|
Workflow,
|
||||||
XCircle,
|
XCircle,
|
||||||
Zap,
|
Zap,
|
||||||
@@ -95,7 +97,15 @@ 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.received',
|
||||||
|
'email.opened',
|
||||||
|
'email.clicked',
|
||||||
|
'email.bounced',
|
||||||
|
'email.complaint',
|
||||||
|
].includes(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActivityItemProps {
|
interface ActivityItemProps {
|
||||||
@@ -173,6 +183,19 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
case 'email.received':
|
||||||
|
return {
|
||||||
|
icon: Inbox,
|
||||||
|
color: 'text-blue-600',
|
||||||
|
bgColor: 'bg-blue-100',
|
||||||
|
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
|
||||||
|
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
|
||||||
|
badge: {
|
||||||
|
label: 'Received',
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
case 'email.opened':
|
case 'email.opened':
|
||||||
return {
|
return {
|
||||||
icon: Eye,
|
icon: Eye,
|
||||||
@@ -226,6 +249,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,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: null,
|
workflows: null,
|
||||||
campaigns: null,
|
campaigns: null,
|
||||||
transactional: null,
|
transactional: null,
|
||||||
|
inbound: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -88,6 +89,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: limitsData.workflows.limit,
|
workflows: limitsData.workflows.limit,
|
||||||
campaigns: limitsData.campaigns.limit,
|
campaigns: limitsData.campaigns.limit,
|
||||||
transactional: limitsData.transactional.limit,
|
transactional: limitsData.transactional.limit,
|
||||||
|
inbound: limitsData.inbound.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [limitsData, form]);
|
}, [limitsData, form]);
|
||||||
@@ -121,6 +123,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
workflows: limitsData.workflows.limit,
|
workflows: limitsData.workflows.limit,
|
||||||
campaigns: limitsData.campaigns.limit,
|
campaigns: limitsData.campaigns.limit,
|
||||||
transactional: limitsData.transactional.limit,
|
transactional: limitsData.transactional.limit,
|
||||||
|
inbound: limitsData.inbound.limit,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
@@ -217,6 +220,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
usage={limitsData.transactional}
|
usage={limitsData.transactional}
|
||||||
currency={limitsData.currency}
|
currency={limitsData.currency}
|
||||||
/>
|
/>
|
||||||
|
<UsageDisplay category="Inbound" usage={limitsData.inbound} currency={limitsData.currency} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -322,6 +326,36 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="inbound"
|
||||||
|
render={({field}) => {
|
||||||
|
const estimatedCost =
|
||||||
|
limitsData?.currency && field.value
|
||||||
|
? formatEmailCost(Number(field.value), limitsData.currency)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Inbound Emails Limit</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="Unlimited"
|
||||||
|
{...field}
|
||||||
|
value={field.value ?? ''}
|
||||||
|
onChange={e => field.onChange(e.target.value === '' ? null : e.target.value)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Maximum inbound emails per month. Leave empty for unlimited.
|
||||||
|
{estimatedCost && <span className="text-neutral-500"> ≈ {estimatedCost}/month</span>}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 pt-4">
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
<Button type="button" variant="outline" onClick={handleCancel}>
|
<Button type="button" variant="outline" onClick={handleCancel}>
|
||||||
Cancel
|
Cancel
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -204,6 +226,22 @@ function CustomNode({
|
|||||||
onMouseLeave={() => setShowActions(false)}
|
onMouseLeave={() => setShowActions(false)}
|
||||||
>
|
>
|
||||||
{/* Action buttons - shown on hover */}
|
{/* Action buttons - shown on hover */}
|
||||||
|
{showActions && data.type === 'TRIGGER' && (
|
||||||
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
|
<Button
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
data.onEdit?.();
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 shadow-md"
|
||||||
|
title="Edit trigger settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{showActions && data.type !== 'TRIGGER' && (
|
{showActions && data.type !== 'TRIGGER' && (
|
||||||
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
<div className="absolute -top-3 -right-3 flex gap-1.5 z-10">
|
||||||
<Button
|
<Button
|
||||||
@@ -300,6 +338,14 @@ function CustomNode({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{data.type === 'TRIGGER' && data.config?.eventName && (
|
||||||
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
|
<Lightbulb className="h-3 w-3" />
|
||||||
|
<span className="truncate">{data.config.eventName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{data.type === 'WEBHOOK' && data.config?.url && (
|
{data.type === 'WEBHOOK' && data.config?.url && (
|
||||||
<div className="mt-3 pt-3 border-t border-neutral-100">
|
<div className="mt-3 pt-3 border-t border-neutral-100">
|
||||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||||
@@ -353,11 +399,23 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
|
|||||||
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
const [stepToDelete, setStepToDelete] = useState<string | null>(null);
|
||||||
|
|
||||||
// Define handlers before they are used in useMemo
|
// Define handlers before they are used in useMemo
|
||||||
const handleEditStep = useCallback((stepId: string) => {
|
const handleEditStep = useCallback(
|
||||||
// This will be handled by the parent component
|
(stepId: string) => {
|
||||||
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
// Check if this is a TRIGGER step
|
||||||
window.dispatchEvent(event);
|
const step = steps.find(s => s.id === stepId);
|
||||||
}, []);
|
|
||||||
|
if (step?.type === 'TRIGGER') {
|
||||||
|
// For TRIGGER steps, open workflow settings instead
|
||||||
|
const event = new CustomEvent('workflow-open-settings');
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
} else {
|
||||||
|
// For other steps, open step editor
|
||||||
|
const event = new CustomEvent('workflow-edit-step', {detail: {stepId}});
|
||||||
|
window.dispatchEvent(event);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[steps],
|
||||||
|
);
|
||||||
|
|
||||||
const handleDeleteStepClick = useCallback((stepId: string) => {
|
const handleDeleteStepClick = useCallback((stepId: string) => {
|
||||||
setStepToDelete(stepId);
|
setStepToDelete(stepId);
|
||||||
@@ -412,6 +470,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 +483,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 +497,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 +571,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 +588,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 +605,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 +626,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 +838,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,16 @@ 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.received,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.delivered">Emails Delivered</SelectItem>
|
||||||
|
<SelectItem value="email.received">Emails Received</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]">
|
||||||
|
|||||||
+1226
-730
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", "receiving-emails", "tracking", "api-keys", "localization", "webhooks"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
---
|
||||||
|
title: Receiving emails
|
||||||
|
description: Receive incoming emails at your verified domain and trigger automated workflows
|
||||||
|
icon: Inbox
|
||||||
|
---
|
||||||
|
|
||||||
|
Plunk can receive emails sent to your verified domain and turn them into `email.received` events. This allows you to build automated workflows that respond to inbound emails, such as support ticket systems, auto-responders, or email-based integrations.
|
||||||
|
|
||||||
|
## How it works
|
||||||
|
|
||||||
|
When someone sends an email to your verified domain (e.g., `[email protected]` or `[email protected]`), Plunk will:
|
||||||
|
|
||||||
|
1. Receive the email through AWS SES
|
||||||
|
2. Automatically create or update a contact for the sender
|
||||||
|
3. Trigger an `email.received` event that can start workflows
|
||||||
|
4. Make the email metadata available to your workflow steps
|
||||||
|
|
||||||
|
The sender is automatically added to your contacts as a subscribed contact, allowing you to respond using Plunk's email sending capabilities.
|
||||||
|
|
||||||
|
## Setting up inbound email
|
||||||
|
|
||||||
|
<div className='fd-steps [&_h3]:fd-step'>
|
||||||
|
|
||||||
|
### Verify your domain
|
||||||
|
|
||||||
|
Before you can receive emails, your domain must be verified in Plunk. Follow the [verifying domains](/guides/verifying-domains) guide to set up the required DKIM, SPF, and MX records for sending.
|
||||||
|
|
||||||
|
### Add the inbound MX record
|
||||||
|
|
||||||
|
In your project settings, navigate to the **Domains** tab and expand your verified domain. You'll see an optional "Inbound Email" section with an MX record configuration:
|
||||||
|
|
||||||
|
- **Type**: MX
|
||||||
|
- **Name**: `yourdomain.com` (your root domain)
|
||||||
|
- **Value**: `10 inbound-smtp.eu-north-1.amazonaws.com`
|
||||||
|
|
||||||
|
Add this MX record to your domain's DNS settings. The priority value `10` ensures that inbound emails are routed to AWS SES for processing.
|
||||||
|
|
||||||
|
<Callout title="Multiple MX records" variant="warning">
|
||||||
|
If you're already using MX records for another email service (like Google Workspace or Microsoft 365), adding this MX
|
||||||
|
record may conflict. You can only have one primary email receiver per domain. Consider using a subdomain (e.g.,
|
||||||
|
`mail.yourdomain.com`) if you need to maintain both services.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
### Wait for DNS propagation
|
||||||
|
|
||||||
|
DNS changes can take anywhere from a few minutes to 48 hours to fully propagate. You can verify the MX record is set correctly using:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig MX yourdomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You should see the AWS SES inbound endpoint in the response.
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## Creating workflows with email.received
|
||||||
|
|
||||||
|
Once your MX record is configured, you can create workflows that respond to incoming emails.
|
||||||
|
|
||||||
|
### Event trigger
|
||||||
|
|
||||||
|
Create a new workflow and use `email.received` as the trigger event. This workflow will run every time an email is received at your domain.
|
||||||
|
|
||||||
|
### Available event data
|
||||||
|
|
||||||
|
The `email.received` event includes the following data that you can use in your workflow steps:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
| ---------------------- | -------- | ---------------------------------------------- |
|
||||||
|
| `messageId` | string | Unique identifier for the received message |
|
||||||
|
| `from` | string | Email address of the sender |
|
||||||
|
| `fromHeader` | string | Full "From" header including display name |
|
||||||
|
| `to` | string | Primary recipient email address |
|
||||||
|
| `subject` | string | Email subject line |
|
||||||
|
| `timestamp` | string | ISO 8601 timestamp when the email was received |
|
||||||
|
| `recipients` | string[] | All recipient email addresses |
|
||||||
|
| `hasContent` | boolean | Whether the email body was captured |
|
||||||
|
| `spamVerdict` | string | Spam check result (e.g., "PASS", "FAIL") |
|
||||||
|
| `virusVerdict` | string | Virus scan result (e.g., "PASS", "FAIL") |
|
||||||
|
| `spfVerdict` | string | SPF authentication result |
|
||||||
|
| `dkimVerdict` | string | DKIM authentication result |
|
||||||
|
| `dmarcVerdict` | string | DMARC authentication result |
|
||||||
|
| `processingTimeMillis` | number | Time taken to process the email |
|
||||||
|
|
||||||
|
You can access these fields in your workflow using variable syntax, for example: `{{event.subject}}` or `{{event.from}}`.
|
||||||
|
|
||||||
|
## Multi-project domains
|
||||||
|
|
||||||
|
If you have verified the same domain in multiple projects, incoming emails will be processed for **all projects** that have the domain verified. Each project will:
|
||||||
|
|
||||||
|
- Create/update the sender as a contact in that project
|
||||||
|
- Trigger the `email.received` event in that project
|
||||||
|
- Run any workflows configured for that event
|
||||||
|
|
||||||
|
This allows you to segment inbound email handling across different projects if needed.
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
<Callout title="Email body content" variant="warning">
|
||||||
|
Currently, the email body content is not stored or made available in the event data. Only metadata (sender, subject,
|
||||||
|
recipients, timestamps, and security verdicts) is captured. The `hasContent` field indicates whether body content was
|
||||||
|
present, but the content itself is not accessible in workflows.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
- **Catch-all addresses**: Plunk receives emails sent to any address at your verified domain (e.g., `[email protected]`). You can use workflow conditions to route emails based on the `to` field.
|
||||||
|
- **Attachments**: Email attachments are not currently captured or stored.
|
||||||
|
- **Email size**: AWS SES has a maximum message size limit of 40 MB for inbound emails.
|
||||||
|
|
||||||
|
## Security considerations
|
||||||
|
|
||||||
|
Plunk captures several security verdicts for each incoming email:
|
||||||
|
|
||||||
|
- **SPF (Sender Policy Framework)**: Verifies the sender's mail server is authorized
|
||||||
|
- **DKIM (DomainKeys Identified Mail)**: Validates the email hasn't been tampered with
|
||||||
|
- **DMARC (Domain-based Message Authentication)**: Combines SPF and DKIM for additional validation
|
||||||
|
- **Spam verdict**: AWS SES's spam detection result
|
||||||
|
- **Virus verdict**: AWS SES's virus scanning result
|
||||||
|
|
||||||
|
You can use these verdicts in workflow conditions to automatically filter or quarantine suspicious emails before processing them.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Emails not being received
|
||||||
|
|
||||||
|
1. **Check DNS propagation**: Verify the MX record is correctly set using `dig MX yourdomain.com`
|
||||||
|
2. **Verify domain**: Ensure your domain is fully verified in Plunk (all DKIM, SPF, and MX records for sending)
|
||||||
|
3. **Check workflow**: Create a simple test workflow with just an `email.received` trigger and a webhook to verify events are being generated
|
||||||
|
4. **Check sender**: Try sending from a different email provider as some may cache DNS records
|
||||||
|
|
||||||
|
### Duplicate events
|
||||||
|
|
||||||
|
If you have the same domain verified in multiple projects, you will receive duplicate `email.received` events (one per project). This is expected behavior. Use project-specific workflows to handle this.
|
||||||
|
|
||||||
|
### Security verdicts failing
|
||||||
|
|
||||||
|
If incoming emails consistently show failing security verdicts:
|
||||||
|
|
||||||
|
- **SPF failures**: The sender's domain may not have SPF configured correctly
|
||||||
|
- **DKIM failures**: The sender's domain may not have DKIM configured, or the email was forwarded/modified in transit
|
||||||
|
- **DMARC failures**: The sender fails both SPF and DKIM checks
|
||||||
|
|
||||||
|
These are issues with the sender's configuration, not your Plunk setup. You can choose to process these emails anyway or filter them using workflow conditions.
|
||||||
@@ -9,9 +9,78 @@ Verifying your domain is a requirement to send emails through Plunk. Domain veri
|
|||||||
## Verifying a domain
|
## Verifying a domain
|
||||||
|
|
||||||
You can verify a domain by adding it in the domain tab of the project settings. Once added, Plunk will provide you with the necessary DNS records to add to your domain's DNS settings.
|
You can verify a domain by adding it in the domain tab of the project settings. Once added, Plunk will provide you with the necessary DNS records to add to your domain's DNS settings.
|
||||||
|
|
||||||
Once you have added the DNS records, it may take some time for the changes to propagate. You can check the verification status in the domain tab of the project settings.
|
Once you have added the DNS records, it may take some time for the changes to propagate. You can check the verification status in the domain tab of the project settings.
|
||||||
|
|
||||||
### DNS Records
|
## DNS Records
|
||||||
- 3 CNAME records for DKIM (DomainKeys Identified Mail) to authenticate your emails.
|
|
||||||
- 1 TXT record for SPF (Sender Policy Framework) to specify which mail servers are authorized
|
Domain verification requires adding several DNS records to your domain. Each record serves a specific purpose in email authentication and delivery.
|
||||||
- 1 MX record to handle bounces and feedback loops
|
|
||||||
|
### DKIM Records (3 CNAME records)
|
||||||
|
|
||||||
|
**DomainKeys Identified Mail (DKIM)** adds a digital signature to your emails, proving they haven't been tampered with during transit.
|
||||||
|
|
||||||
|
You'll need to add **3 CNAME records** provided by Plunk. These records contain cryptographic keys that email receivers use to verify your emails are authentic.
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
|
||||||
|
- Prevents email spoofing and tampering
|
||||||
|
- Improves deliverability and inbox placement
|
||||||
|
- Required by most email providers (Gmail, Outlook, etc.)
|
||||||
|
|
||||||
|
### SPF Record (1 TXT record)
|
||||||
|
|
||||||
|
**Sender Policy Framework (SPF)** specifies which mail servers are authorized to send emails on behalf of your domain.
|
||||||
|
|
||||||
|
You'll need to add **1 TXT record** that lists the authorized sending servers.
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
|
||||||
|
- Prevents unauthorized servers from sending emails using your domain
|
||||||
|
- Reduces the likelihood of your domain being used for spam
|
||||||
|
- Works together with DKIM for complete authentication
|
||||||
|
|
||||||
|
### Bounce Handling (1 MX record)
|
||||||
|
|
||||||
|
This **MX record** allows Plunk to receive bounce notifications and spam complaints from email providers.
|
||||||
|
|
||||||
|
**Why it matters:**
|
||||||
|
|
||||||
|
- Automatically tracks which contacts have bounced or complained
|
||||||
|
- Helps maintain your sender reputation
|
||||||
|
- Prevents sending to invalid email addresses
|
||||||
|
- Required for deliverability monitoring
|
||||||
|
|
||||||
|
<Callout title="Receiving inbound emails" variant="idea">
|
||||||
|
The MX record above is for sending emails (handling bounces and complaints). If you want to **receive** emails at your
|
||||||
|
domain and trigger workflows, see the [receiving emails](/guides/receiving-emails) guide for additional MX record
|
||||||
|
setup.
|
||||||
|
</Callout>
|
||||||
|
|
||||||
|
## Verification Status
|
||||||
|
|
||||||
|
After adding all DNS records, Plunk will automatically check the verification status. Verification typically completes within a few minutes but can take up to 72 hours depending on DNS propagation.
|
||||||
|
|
||||||
|
You can check the status in the Domains section of your project settings. Each record type will show as verified once detected.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Records not verifying
|
||||||
|
|
||||||
|
If your DNS records aren't verifying after 24 hours:
|
||||||
|
|
||||||
|
1. **Double-check the values**: Ensure you copied the exact values without extra spaces
|
||||||
|
2. **Check DNS propagation**: Use tools like `dig` or online DNS checkers to verify the records are published
|
||||||
|
3. **TTL settings**: Some DNS providers cache records. Try lowering the TTL (Time To Live) value
|
||||||
|
4. **Contact your DNS provider**: Some providers have specific requirements or interfaces for adding these record types
|
||||||
|
|
||||||
|
### Emails still going to spam
|
||||||
|
|
||||||
|
Even with a verified domain, emails may go to spam if:
|
||||||
|
|
||||||
|
- Your content triggers spam filters (excessive links, suspicious keywords)
|
||||||
|
- Your sender reputation is new or low
|
||||||
|
- Recipients have marked your emails as spam in the past
|
||||||
|
- You're sending to invalid or unengaged contacts
|
||||||
|
|
||||||
|
Follow email best practices and maintain good [list hygiene](/guides/list-hygiene) to improve deliverability.
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
| `email.received` | An email was received at your verified domain (requires [inbound email setup](/guides/receiving-emails)) |
|
||||||
|
|
||||||
|
### 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.6.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",
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "EmailSourceType" ADD VALUE 'INBOUND';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "projects" ADD COLUMN "billingLimitInbound" INTEGER;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "EmailStatus" ADD VALUE 'RECEIVED';
|
||||||
@@ -53,6 +53,7 @@ model Project {
|
|||||||
billingLimitWorkflows Int? // Max workflow emails per month
|
billingLimitWorkflows Int? // Max workflow emails per month
|
||||||
billingLimitCampaigns Int? // Max campaign emails per month
|
billingLimitCampaigns Int? // Max campaign emails per month
|
||||||
billingLimitTransactional Int? // Max transactional emails per month
|
billingLimitTransactional Int? // Max transactional emails per month
|
||||||
|
billingLimitInbound Int? // Max inbound emails per month
|
||||||
|
|
||||||
// Email Tracking
|
// Email Tracking
|
||||||
tracking TrackingMode @default(ENABLED) // Open and click tracking mode
|
tracking TrackingMode @default(ENABLED) // Open and click tracking mode
|
||||||
@@ -750,6 +751,7 @@ enum EmailSourceType {
|
|||||||
TRANSACTIONAL // Sent via API call
|
TRANSACTIONAL // Sent via API call
|
||||||
CAMPAIGN // Sent as part of broadcast
|
CAMPAIGN // Sent as part of broadcast
|
||||||
WORKFLOW // Sent via workflow automation
|
WORKFLOW // Sent via workflow automation
|
||||||
|
INBOUND // Received via SES inbound
|
||||||
}
|
}
|
||||||
|
|
||||||
enum EmailStatus {
|
enum EmailStatus {
|
||||||
@@ -757,6 +759,7 @@ enum EmailStatus {
|
|||||||
SENDING // Currently being sent
|
SENDING // Currently being sent
|
||||||
SENT // Successfully sent to provider
|
SENT // Successfully sent to provider
|
||||||
DELIVERED // Confirmed delivered
|
DELIVERED // Confirmed delivered
|
||||||
|
RECEIVED // Received by platform (inbound)
|
||||||
OPENED // Recipient opened email
|
OPENED // Recipient opened email
|
||||||
CLICKED // Recipient clicked link
|
CLICKED // Recipient clicked link
|
||||||
BOUNCED // Bounced (hard or soft)
|
BOUNCED // Bounced (hard or soft)
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ 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'};
|
||||||
|
import esTranslations from './locales/es.json' with {type: 'json'};
|
||||||
|
|
||||||
export {
|
export {
|
||||||
SUPPORTED_LANGUAGES,
|
SUPPORTED_LANGUAGES,
|
||||||
DEFAULT_LANGUAGE,
|
DEFAULT_LANGUAGE,
|
||||||
@@ -34,6 +40,11 @@ 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,
|
||||||
|
es: esTranslations,
|
||||||
};
|
};
|
||||||
|
|
||||||
// In-memory cache for loaded translations
|
// In-memory cache for loaded translations
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ 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: '🇵🇱'},
|
||||||
|
{code: 'es', name: 'Spanish (Spain)', nativeName: 'Español (España)', 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": "Cancelar suscripción",
|
||||||
|
"description": "Lamentamos que te vayas. ¿Estás seguro de que quieres cancelar la suscripción de {email} para dejar de recibir correos electrónicos?",
|
||||||
|
"button": "Cancelar suscripción",
|
||||||
|
"buttonLoading": "Cancelando suscripción...",
|
||||||
|
"managePreferences": "Gestionar preferencias en su lugar",
|
||||||
|
"successTitle": "Suscripción cancelada",
|
||||||
|
"successDescription": "Se ha cancelado la suscripción de {email}. No recibirás más correos electrónicos de nuestra parte.",
|
||||||
|
"changedMind": "¿Has cambiado de opinión?",
|
||||||
|
"subscribeAgain": "Suscribirse de nuevo"
|
||||||
|
},
|
||||||
|
"subscribe": {
|
||||||
|
"title": "Suscribirse a las novedades",
|
||||||
|
"description": "¿Te gustaría suscribir {email} para recibir correos electrónicos?",
|
||||||
|
"button": "Suscribirse",
|
||||||
|
"buttonLoading": "Suscribiendo...",
|
||||||
|
"successTitle": "¡Suscripción completada!",
|
||||||
|
"successDescription": "{email} está ahora suscrito para recibir correos electrónicos de nuestra parte."
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Gestionar preferencias",
|
||||||
|
"description": "Gestiona las preferencias de correo electrónico de {email}",
|
||||||
|
"subscriptionLabel": "Suscripción de correo electrónico",
|
||||||
|
"subscribedStatus": "Actualmente estás suscrito para recibir correos electrónicos",
|
||||||
|
"unsubscribedStatus": "Actualmente tienes cancelada la suscripción a los correos electrónicos",
|
||||||
|
"subscribedSuccess": "¡Suscripción realizada con éxito!",
|
||||||
|
"unsubscribedSuccess": "¡Suscripción cancelada con éxito!",
|
||||||
|
"unsubscribeCompletely": "Cancelar suscripción completamente",
|
||||||
|
"subscribeToEmails": "Suscribirse a los correos electrónicos",
|
||||||
|
"disclaimer": "Esta página te permite gestionar tus preferencias de correo electrónico. El estado de tu suscripción se actualiza en tiempo real."
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Cargando...",
|
||||||
|
"error": "Error"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"footer": {
|
||||||
|
"unsubscribeText": "Has recibido este correo electrónico porque aceptaste recibir correos electrónicos de {projectName}. Si ya no deseas recibir correos electrónicos como este, por favor",
|
||||||
|
"updatePreferences": "actualiza tus preferencias"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
@@ -178,6 +180,7 @@ export const WorkflowSchemas = {
|
|||||||
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
|
triggerType: z.nativeEnum(WorkflowTriggerType).optional(),
|
||||||
triggerConfig: jsonSchema.optional(),
|
triggerConfig: jsonSchema.optional(),
|
||||||
enabled: z.boolean().optional(),
|
enabled: z.boolean().optional(),
|
||||||
|
allowReentry: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
addStep: z.object({
|
addStep: z.object({
|
||||||
type: z.nativeEnum(WorkflowStepType),
|
type: z.nativeEnum(WorkflowStepType),
|
||||||
@@ -206,6 +209,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(),
|
||||||
@@ -416,6 +440,7 @@ export const BillingLimitSchemas = {
|
|||||||
workflows: z.coerce.number().int().positive().nullable(),
|
workflows: z.coerce.number().int().positive().nullable(),
|
||||||
campaigns: z.coerce.number().int().positive().nullable(),
|
campaigns: z.coerce.number().int().positive().nullable(),
|
||||||
transactional: z.coerce.number().int().positive().nullable(),
|
transactional: z.coerce.number().int().positive().nullable(),
|
||||||
|
inbound: z.coerce.number().int().positive().nullable(),
|
||||||
}),
|
}),
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -9,9 +9,11 @@ export enum ActivityType {
|
|||||||
EVENT_TRIGGERED = 'event.triggered',
|
EVENT_TRIGGERED = 'event.triggered',
|
||||||
EMAIL_SENT = 'email.sent',
|
EMAIL_SENT = 'email.sent',
|
||||||
EMAIL_DELIVERED = 'email.delivered',
|
EMAIL_DELIVERED = 'email.delivered',
|
||||||
|
EMAIL_RECEIVED = 'email.received',
|
||||||
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',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export interface BillingLimitsResponse {
|
|||||||
workflows: CategoryUsage;
|
workflows: CategoryUsage;
|
||||||
campaigns: CategoryUsage;
|
campaigns: CategoryUsage;
|
||||||
transactional: CategoryUsage;
|
transactional: CategoryUsage;
|
||||||
|
inbound: CategoryUsage;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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