Compare commits

..
75 Commits
Author SHA1 Message Date
Dries Augustyns a00473314f Merge pull request #255 from useplunk/release-please--branches--next--components--plunk
chore(next): release 0.5.0
2026-02-17 19:16:13 +01:00
github-actions[bot] 2dabcb2419 chore(next): release 0.5.0 2026-02-17 18:09:45 +00:00
Dries Augustyns d9ae171cc7 chore: fix linting errors 2026-02-17 19:09:01 +01:00
Dries Augustyns e4e334c77a fix: Center "Add Step" nodes below parent nodes and update positions on drag 2026-02-17 19:06:22 +01:00
Dries Augustyns 78d3d224af feat: Add support for custom email recipients in workflow steps 2026-02-17 18:57:15 +01:00
Dries Augustyns f9b1354460 fix: Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings 2026-02-17 18:19:58 +01:00
Dries Augustyns 95e5ec8485 Merge pull request #290 from useplunk/inbound
Inbound
2026-02-17 16:56:29 +01:00
Dries Augustyns d050b55baa feat: Add inbound handling 2026-02-17 16:52:56 +01:00
Dries Augustyns 7964563b62 feat: Add advanced DNS configuration 2026-02-17 16:33:34 +01:00
Dries Augustyns e01dc4066c Merge pull request #289 from useplunk/inbound
feat: Add initial handling in webhook for inbound
2026-02-17 16:07:52 +01:00
Dries Augustyns c286f49097 feat: Add initial handling in webhook for inbound 2026-02-17 16:03:50 +01:00
Dries Augustyns 4c81d9ec04 feat: add documentation link and redirect to WIKI_URI 2026-02-17 10:30:17 +01:00
Dries Augustyns a928666dfc fix: Update contact subscription logic for upsert operations 2026-02-17 10:25:01 +01:00
Dries Augustyns d55dda3127 feat: add minimum thresholds for bounce and complaint rates 2026-02-16 11:03:54 +01:00
Dries Augustyns d637147f25 Merge pull request #284 from useplunk/dependabot/npm_and_yarn/webpack-5.105.0
chore(deps): bump webpack from 5.103.0 to 5.105.0
2026-02-16 10:20:22 +01:00
Dries Augustyns c1ed23c967 Merge pull request #286 from useplunk/dependabot/npm_and_yarn/markdown-it-14.1.1
chore(deps): bump markdown-it from 14.1.0 to 14.1.1
2026-02-16 10:20:08 +01:00
Dries Augustyns 46001619e8 Merge pull request #287 from useplunk/dependabot/npm_and_yarn/qs-6.14.2
chore(deps): bump qs from 6.14.1 to 6.14.2
2026-02-16 10:19:52 +01:00
Dries Augustyns 7a6df7760b Merge pull request #288 from bpotmalnik/feat/add-polish
feat(i18n): add Polish locale translations
2026-02-16 10:00:38 +01:00
Bart Potmalnik d2d779c68f feat(i18n): add Polish locale translations
Add polish translations

Ref #246
2026-02-15 21:43:29 +01:00
dependabot[bot] bbc12e0029 chore(deps): bump qs from 6.14.1 to 6.14.2
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.14.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-14 12:57:41 +00:00
dependabot[bot] 125718bb94 chore(deps): bump markdown-it from 14.1.0 to 14.1.1
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.1.0 to 14.1.1.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.1.0...14.1.1)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 14.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-13 20:46:34 +00:00
Dries Augustyns 35d67c6b42 Merge pull request #285 from yumhum/i18n-czech-locale 2026-02-11 08:39:37 +01:00
Jan Wunsch 14c471e4ca feat(i18n): add Czech locale translations
Add Czech (cs) translations for contact-facing pages and email footers,
and wire up the locale in the i18n system with the correct flag.
2026-02-11 01:48:38 +01:00
Dries Augustyns 42ceb6edc0 refactor: remove unnecessary logging for segment processing 2026-02-10 20:23:31 +01:00
Dries Augustyns 5bce1d74ff docs: add webhooks documentation for real-time event handling 2026-02-09 19:01:16 +01:00
Dries Augustyns b4404f698e docs: update contacts documentation to include subscription state and email delivery rules 2026-02-09 18:45:01 +01:00
dependabot[bot] e4625894c9 chore(deps): bump webpack from 5.103.0 to 5.105.0
Bumps [webpack](https://github.com/webpack/webpack) from 5.103.0 to 5.105.0.
- [Release notes](https://github.com/webpack/webpack/releases)
- [Changelog](https://github.com/webpack/webpack/blob/main/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack/compare/v5.103.0...v5.105.0)

---
updated-dependencies:
- dependency-name: webpack
  dependency-version: 5.105.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-02-06 08:04:36 +00:00
Dries Augustyns af87d80baa Merge pull request #280 from useplunk/dependabot/npm_and_yarn/next-16.1.6
chore(deps): bump next from 16.0.1 to 16.1.6
2026-02-06 09:01:56 +01:00
Dries Augustyns 1f84c9113c Merge branch 'next' of https://github.com/useplunk/plunk into next 2026-02-06 09:01:33 +01:00
Dries Augustyns 0cf0a26a97 feat: Integrate NuqsAdapter for improved state management and query handling 2026-02-06 09:01:25 +01:00
Dries Augustyns 0d4b694208 fix: Implement merging for activity updates to preserve component state 2026-02-06 08:55:24 +01:00
dependabot[bot] 20ffb2b8bc chore(deps): bump next from 16.0.1 to 16.1.6
Bumps [next](https://github.com/vercel/next.js) from 16.0.1 to 16.1.6.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.1...v16.1.6)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-29 14:12:13 +00:00
Dries Augustyns 0af79eb84b Merge pull request #279 from useplunk/dependabot/npm_and_yarn/tar-7.5.7 2026-01-29 15:10:06 +01:00
Dries Augustyns e2e98149cb Merge pull request #278 from useplunk/dependabot/npm_and_yarn/next-16.1.5 2026-01-29 15:09:44 +01:00
Dries Augustyns f55ab31ae5 Merge pull request #277 from useplunk/dependabot/npm_and_yarn/lodash-4.17.23 2026-01-29 15:09:24 +01:00
Dries Augustyns 61a65d01c7 Merge pull request #275 from useplunk/dependabot/npm_and_yarn/diff-4.0.4 2026-01-29 15:09:05 +01:00
dependabot[bot] 7aa3b74c6f chore(deps): bump tar from 7.5.3 to 7.5.7
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.3 to 7.5.7.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.3...v7.5.7)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.7
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-29 14:02:08 +00:00
dependabot[bot] 7647109fb6 chore(deps): bump next from 16.0.10 to 16.1.5
Bumps [next](https://github.com/vercel/next.js) from 16.0.10 to 16.1.5.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.10...v16.1.5)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.1.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-27 20:11:43 +00:00
Dries Augustyns ad0c17da56 fix: Enhance email activity filtering by adding date range checks 2026-01-26 09:24:49 +01:00
dependabot[bot] 93ca6b8d94 chore(deps): bump lodash from 4.17.21 to 4.17.23
Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash
  dependency-version: 4.17.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-23 06:43:35 +00:00
Dries Augustyns c85df75d53 feat: Add bounce and complaint filter to activity feed 2026-01-21 20:52:23 +01:00
Dries Augustyns e16093a264 chore: Increaes complaint thresholds 2026-01-21 20:46:33 +01:00
dependabot[bot] a54c8139b1 chore(deps): bump diff from 4.0.2 to 4.0.4
Bumps [diff](https://github.com/kpdecker/jsdiff) from 4.0.2 to 4.0.4.
- [Changelog](https://github.com/kpdecker/jsdiff/blob/master/release-notes.md)
- [Commits](https://github.com/kpdecker/jsdiff/compare/v4.0.2...v4.0.4)

---
updated-dependencies:
- dependency-name: diff
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-20 22:25:03 +00:00
Dries Augustyns 8de79e37e9 Merge pull request #269 from useplunk/dependabot/npm_and_yarn/undici-6.23.0 2026-01-17 20:28:36 +01:00
Dries Augustyns c662ecdbb2 Merge pull request #274 from useplunk/dependabot/npm_and_yarn/tar-7.5.3 2026-01-17 20:28:17 +01:00
dependabot[bot] 8f6f63e572 chore(deps): bump tar from 7.5.2 to 7.5.3
Bumps [tar](https://github.com/isaacs/node-tar) from 7.5.2 to 7.5.3.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v7.5.2...v7.5.3)

---
updated-dependencies:
- dependency-name: tar
  dependency-version: 7.5.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-17 15:52:37 +00:00
Dries Augustyns 22c22aba1b Merge pull request #273 from wettiemans/fix/242-template-type-dropdown 2026-01-17 12:40:18 +01:00
Dries AugustynsandCopilot de106f9d91 Update apps/web/src/pages/templates/[id].tsx
Co-authored-by: Copilot <[email protected]>
2026-01-17 12:30:00 +01:00
Jan Behrends 8a38766c02 fix: Ensure template type is loaded before rendering Select
Addresses #242. The Select component renders with value={undefined}
before useEffect populates editedTemplate.type. This triggers a React
warning: 'Select is changing from uncontrolled to controlled.'

On the hosted environment, this likely causes Radix UI's data-placeholder
attribute to remain set on the trigger, with the selected value text
not being rendered despite the value being loaded in state.

This change extends the loading condition to wait for editedTemplate.type,
ensuring the Select never renders in an undefined/uncontrolled state.
2026-01-16 13:09:14 +01:00
Dries Augustyns 3a16ad3221 chore: Add bundled notification for segment membership updates 2026-01-15 19:47:12 +01:00
Dries Augustyns ec402cb9b3 Merge pull request #270 from mmihalev/next 2026-01-15 13:06:29 +01:00
Milen Mihalev 663bc2be8d feat(i18n): Add Bulgarian translations 2026-01-15 11:50:25 +02:00
Dries Augustyns df28e2dc7d Merge pull request #268 from marcelscruz/next 2026-01-15 09:40:52 +01:00
dependabot[bot] 79fe9e94c7 chore(deps): bump undici from 6.22.0 to 6.23.0
Bumps [undici](https://github.com/nodejs/undici) from 6.22.0 to 6.23.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.22.0...v6.23.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.23.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-01-15 00:25:59 +00:00
Marcel Cruz c2afb2dfab feat(i18n): Add Portuguese translations 2026-01-15 00:32:37 +01:00
Dries Augustyns 0d1ed0ec67 Merge pull request #263 from 5h0ov/new-olderthan-segmentfilter 2026-01-13 10:30:56 +01:00
Dries Augustyns 99526ebfc2 chore: Rename within field label to align with new older than 2026-01-13 10:25:10 +01:00
Dries Augustyns e6baace00d feat: Remove domain from AWS if no longer in use by other projects 2026-01-13 10:19:54 +01:00
Dries Augustyns 743155b06a chore: Add missing variables to turbo.json 2026-01-12 20:15:36 +01:00
Dries Augustyns 0a67a8278f feat: Add additional checks for website, NS records and personal emails 2026-01-12 20:02:08 +01:00
Dries Augustyns 26800a8553 fix: Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation 2026-01-12 18:49:30 +01:00
Dries Augustyns 93165644af feat: Ability to disable signups and disable email verification for self-hosters 2026-01-12 15:20:14 +01:00
Dries Augustyns 4db1ccc3fc fix: Refactor Redis keys to prevent multiple messages on concurrent requests 2026-01-12 08:33:51 +01:00
Dries Augustyns 06c4d644b7 Merge pull request #262 from 5h0ov/development-enhancements 2026-01-12 08:07:47 +01:00
Shuvadipta Das b5fa21a57c feat: add "olderThan" segment filter operator
- Added `olderThan` and `triggeredOlderThan` operators to types and schemas
- Implemented backend logic for `olderThan` operator in SegmentService
- Updated SegmentFilterBuilder and shared index/types files to support new time-based operators
2026-01-12 11:10:53 +05:30
Shuvadipta Das ddbe5237fe fix: added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api 2026-01-12 11:07:53 +05:30
Dries Augustyns 18e17cf198 chore: Remove default tag from issue template 2026-01-11 11:53:36 +01:00
Dries Augustyns 6a6772d5ed Update issue templates 2026-01-11 11:51:38 +01:00
Dries Augustyns 50042cea27 chore: Clean up variable replacement paths 2026-01-11 09:09:41 +01:00
Dries Augustyns 3ccf890a21 chore: Clean up variable replacement paths 2026-01-11 08:57:19 +01:00
Dries Augustyns d57f6c81cb chore: Clean up variable replacement paths 2026-01-11 08:37:01 +01:00
Dries Augustyns c07816c2a1 fix: Update URL replacement logic to handle runtime paths and add warnings for missing files 2026-01-11 08:26:59 +01:00
Dries Augustyns 73da0a5136 Merge pull request #257 from mmihalev/next 2026-01-10 10:20:57 +01:00
Milen Mihalev d12b797ebe Use environment variables for SMTP port configuration 2026-01-09 15:38:59 +02:00
Dries Augustyns 7df43d8553 fix: Improve bounce handling logic to differentiate between permanent and transient bounces 2026-01-09 11:48:20 +01:00
71 changed files with 3318 additions and 1243 deletions
+15
View File
@@ -145,6 +145,21 @@ SMTP_DOMAIN=smtp.example.com
# Default: true
# 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)
# ========================================
@@ -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.
+61
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "0.4.0"
".": "0.5.0"
}
+1 -1
View File
@@ -2,7 +2,7 @@ nodeLinker: node-modules
# Support multiple architectures - download pre-built binaries instead of compiling
supportedArchitectures:
os: [ "linux", "darwin" ]
os: [ "linux", "darwin", "win32" ]
cpu: [ "x64", "arm64" ]
libc: [ "glibc" ]
+50
View File
@@ -1,5 +1,55 @@
# Changelog
## [0.5.0](https://github.com/useplunk/plunk/compare/v0.4.0...v0.5.0) (2026-02-17)
### Features
* Ability to disable signups and disable email verification for self-hosters ([9316564](https://github.com/useplunk/plunk/commit/93165644af1ebcd7d5eb1900b13e5e38c1af0262))
* add "olderThan" segment filter operator ([b5fa21a](https://github.com/useplunk/plunk/commit/b5fa21a57cbf491e0438eb29fc9419063fa2aea7))
* Add additional checks for website, NS records and personal emails ([0a67a82](https://github.com/useplunk/plunk/commit/0a67a8278f45d89b1abdf55be1cf251a470595cf))
* Add advanced DNS configuration ([7964563](https://github.com/useplunk/plunk/commit/7964563b620868279f5fb6baab0d7499c41f51ed))
* Add bounce and complaint filter to activity feed ([c85df75](https://github.com/useplunk/plunk/commit/c85df75d539f7e5500a9a109966b0e6d654d4022))
* add documentation link and redirect to WIKI_URI ([4c81d9e](https://github.com/useplunk/plunk/commit/4c81d9ec04003550b140d884f37ebbf13137166d))
* Add inbound handling ([d050b55](https://github.com/useplunk/plunk/commit/d050b55baaf46306be3fb6e5ad683205e158bd65))
* Add initial handling in webhook for inbound ([e01dc40](https://github.com/useplunk/plunk/commit/e01dc4066c5e5eb5e0e69ef92f8a5bb7786e5a91))
* Add initial handling in webhook for inbound ([c286f49](https://github.com/useplunk/plunk/commit/c286f490974d8dfe9c94695c63e6b48216b8052d))
* add minimum thresholds for bounce and complaint rates ([d55dda3](https://github.com/useplunk/plunk/commit/d55dda312718890231d1a428448e607792799c84))
* Add support for custom email recipients in workflow steps ([78d3d22](https://github.com/useplunk/plunk/commit/78d3d224af60cf5b58c6b18fdd0921320912fb09))
* **i18n:** Add Bulgarian translations ([663bc2b](https://github.com/useplunk/plunk/commit/663bc2be8da69733d2bbc53c6b1bfcc36ff55cf8))
* **i18n:** add Czech locale translations ([14c471e](https://github.com/useplunk/plunk/commit/14c471e4cadbe1bbf90e53522134fad3d81ff107))
* **i18n:** add Polish locale translations ([7a6df77](https://github.com/useplunk/plunk/commit/7a6df7760bc73dfe80dcf2d37612e320f6de1d30))
* **i18n:** add Polish locale translations ([d2d779c](https://github.com/useplunk/plunk/commit/d2d779c68f958a6ac0e77b7588e92a5c705a8fd4)), closes [#246](https://github.com/useplunk/plunk/issues/246)
* **i18n:** Add Portuguese translations ([c2afb2d](https://github.com/useplunk/plunk/commit/c2afb2dfab977ae74f3b694601a1f2d0353660a8))
* Integrate NuqsAdapter for improved state management and query handling ([0cf0a26](https://github.com/useplunk/plunk/commit/0cf0a26a97e608f654ed3046c1a46d414557e3ee))
* Remove domain from AWS if no longer in use by other projects ([e6baace](https://github.com/useplunk/plunk/commit/e6baace00d5f7a109e717b8a996bc2cda52cc9fa))
### Bug Fixes
* added missing services:down script, added "win32" to supportedArchitectures for yarn package installation, added missing required WIKI_URI to .env.example of api ([ddbe523](https://github.com/useplunk/plunk/commit/ddbe5237fe7012e197e3b398aebc8e2921a4328a))
* Center "Add Step" nodes below parent nodes and update positions on drag ([e4e334c](https://github.com/useplunk/plunk/commit/e4e334c77a7ba41e5a60b69b9c64fa3bb72f1e74))
* Enhance email activity filtering by adding date range checks ([ad0c17d](https://github.com/useplunk/plunk/commit/ad0c17da566a26a296229c05d9b4e18cf7d401ad))
* Ensure template type is loaded before rendering Select ([8a38766](https://github.com/useplunk/plunk/commit/8a38766c025afd5ef15fee4b7c5baea97a45ec48))
* Implement merging for activity updates to preserve component state ([0d4b694](https://github.com/useplunk/plunk/commit/0d4b694208fd8e516e1f50f7384d1ac5bd6561e0))
* Improve bounce handling logic to differentiate between permanent and transient bounces ([7df43d8](https://github.com/useplunk/plunk/commit/7df43d8553eca93b601915ea4deaf59233e848c7))
* Improve email verification logic by prioritizing MX record checks and clarifying domain existence validation ([26800a8](https://github.com/useplunk/plunk/commit/26800a85538fc90aa05fc43b1cc33cef95d8fb32))
* Refactor Redis keys to prevent multiple messages on concurrent requests ([4db1ccc](https://github.com/useplunk/plunk/commit/4db1ccc3fc59edb0187d8e2223f45bb0bce6deb5))
* Remove 'Optional' label from MAIL FROM Domain and Inbound Email headings ([f9b1354](https://github.com/useplunk/plunk/commit/f9b135446040de099484db66139560f43bbf027e))
* Update contact subscription logic for upsert operations ([a928666](https://github.com/useplunk/plunk/commit/a928666dfcdc65c90602051c79b3c008282674aa))
* Update URL replacement logic to handle runtime paths and add warnings for missing files ([c07816c](https://github.com/useplunk/plunk/commit/c07816c2a1b029d83400128f4720263e975214ad))
### Code Refactoring
* remove unnecessary logging for segment processing ([42ceb6e](https://github.com/useplunk/plunk/commit/42ceb6edc03ebc6f14c6d399e859914ac09f1ab6))
### Documentation
* add webhooks documentation for real-time event handling ([5bce1d7](https://github.com/useplunk/plunk/commit/5bce1d74fffb927bcbb2c7df624fde089101efc8))
* update contacts documentation to include subscription state and email delivery rules ([b4404f6](https://github.com/useplunk/plunk/commit/b4404f698ec28a59f32d728c15c59c8b7765d377))
## [0.4.0](https://github.com/useplunk/plunk/compare/v0.3.0...v0.4.0) (2026-01-08)
+10 -3
View File
@@ -64,9 +64,9 @@ to run them separately (e.g., for debugging), use `dev:server` and `dev:worker`
### Applications (`apps/`)
- **api**: Express.js API server with TypeScript (ESM), uses @overnightjs/core
- HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- HTTP API endpoints for the platform
- Background cron jobs (workflow processor, domain verification)
- **Worker process** (separate): BullMQ worker for processing email, campaign, and workflow queues
- **web**: Next.js app (Pages Router) - Main platform (next-app.useplunk.com)
- **landing**: Next.js app (Pages Router) - Marketing site (next.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)
- 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)
- 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:**
@@ -159,6 +165,7 @@ Required for builds and deployment (see turbo.json and .env.example):
client-side access
## Plugins
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.
+1
View File
@@ -16,6 +16,7 @@ JWT_SECRET=hBx9Xh8J6KOMAGAsSjvcZJBT5TWyIkFX
USE_HTTPS=false
API_URI=http://localhost:8080
WIKI_URI=http://localhost:1000
DASHBOARD_URI=http://localhost:3000
LANDING_URI=http://localhost:4000
@@ -850,6 +850,17 @@ describe('Actions API Integration Tests', () => {
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', () => {
@@ -905,9 +916,9 @@ describe('Actions API Integration Tests', () => {
// Track event for new contact without specifying subscribed
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);
});
+6
View File
@@ -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
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
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
+4 -2
View File
@@ -72,11 +72,12 @@ export class Actions {
// Create or update contact with persistent data only
// ContactService.upsert will filter out non-persistent fields
// Event tracking should subscribe contacts by default
const contact = await ContactService.upsert(
auth.projectId,
email,
data as Record<string, unknown> | undefined,
subscribed,
subscribed ?? true,
);
// Track the event with ALL data (persistent + non-persistent)
@@ -266,7 +267,8 @@ export class Actions {
: (data as Record<string, unknown> | undefined);
// 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
const mergedData = ContactService.getMergedData(contact, data as Record<string, unknown> | undefined);
+34 -22
View File
@@ -7,6 +7,7 @@ import * as React from 'react';
import {
DASHBOARD_URI,
DISABLE_SIGNUPS,
EMAIL_VERIFICATION_RATE_LIMIT,
EMAIL_VERIFICATION_RATE_WINDOW,
GITHUB_OAUTH_ENABLED,
@@ -15,6 +16,7 @@ import {
PASSWORD_RESET_RATE_LIMIT,
PLUNK_ENABLED,
TOKEN_EXPIRY_SECONDS,
VERIFY_EMAIL_ON_SIGNUP,
} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import {redis, REDIS_ONE_MINUTE} from '../database/redis.js';
@@ -63,33 +65,43 @@ export class Auth {
@Post('signup')
@CatchAsync
public async signup(req: Request, res: Response, _next: NextFunction) {
const {email, password} = AuthenticationSchemas.login.parse(req.body);
// 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);
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
return res.json({
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);
if (user) {
+4
View File
@@ -3,6 +3,7 @@ import type {Request, Response} from 'express';
import {
API_URI,
AWS_SES_REGION,
DASHBOARD_URI,
GITHUB_OAUTH_ENABLED,
GOOGLE_OAUTH_ENABLED,
@@ -59,6 +60,9 @@ export class Config {
: null,
},
},
aws: {
sesRegion: AWS_SES_REGION,
},
});
}
}
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
DISABLE_SIGNUPS,
GITHUB_OAUTH_CLIENT,
GITHUB_OAUTH_ENABLED,
GITHUB_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
@@ -81,6 +83,11 @@ export class Github {
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
+7
View File
@@ -4,11 +4,13 @@ import type {NextFunction, Request, Response} from 'express';
import {
API_URI,
DASHBOARD_URI,
DISABLE_SIGNUPS,
GOOGLE_OAUTH_CLIENT,
GOOGLE_OAUTH_ENABLED,
GOOGLE_OAUTH_SECRET,
} from '../../app/constants.js';
import {prisma} from '../../database/prisma.js';
import {BadRequest} from '../../exceptions/index.js';
import {jwt} from '../../middleware/auth.js';
import {NtfyService} from '../../services/NtfyService.js';
import {UserService} from '../../services/UserService.js';
@@ -71,6 +73,11 @@ export class Google {
let isNewUser = false;
if (!user) {
// Check if signups are disabled
if (DISABLE_SIGNUPS) {
throw new BadRequest('New user signups are currently disabled');
}
user = await prisma.user.create({
data: {
email,
+169 -24
View File
@@ -8,6 +8,7 @@ import type Stripe from 'stripe';
import {STRIPE_ENABLED, STRIPE_WEBHOOK_SECRET} from '../app/constants.js';
import {stripe} from '../app/stripe.js';
import {prisma} from '../database/prisma.js';
import {ContactService} from '../services/ContactService.js';
import {EventService} from '../services/EventService.js';
import {NtfyService} from '../services/NtfyService.js';
import {SecurityService} from '../services/SecurityService.js';
@@ -21,7 +22,8 @@ import {CatchAsync} from '../utils/asyncHandler.js';
export class Webhooks {
/**
* 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')
@CatchAsync
@@ -67,6 +69,114 @@ export class Webhooks {
// Parse the nested SES event from the Message field
const body = JSON.parse(req.body.Message);
// Check if this is an inbound email notification (SES Receiving)
if (body.notificationType === 'Received') {
signale.info('[WEBHOOK] Received inbound email notification from SES');
try {
// Extract recipient addresses from the inbound email
const recipients = body.receipt?.recipients || [];
if (recipients.length === 0) {
signale.warn('[WEBHOOK] No recipients found in inbound email');
return res.status(200).json({success: true, message: 'No recipients found'});
}
// For each recipient, identify the domain and create events
for (const recipient of recipients) {
const recipientEmail = recipient as string;
const domain = recipientEmail.split('@')[1];
if (!domain) {
signale.warn('[WEBHOOK] Invalid recipient email format:', recipientEmail);
continue;
}
// Find ALL projects that have this domain verified
// A domain can be shared across multiple projects if users are members of both
const domainRecords = await prisma.domain.findMany({
where: {
domain,
verified: true, // Only process emails for verified domains
},
include: {
project: true,
},
});
if (domainRecords.length === 0) {
signale.info(`[WEBHOOK] No verified domain found for: ${domain}`);
continue;
}
signale.info(
`[WEBHOOK] Found ${domainRecords.length} project(s) with verified domain ${domain}. Processing inbound email for all.`,
);
// Extract sender information (same for all projects)
const senderEmail = body.mail?.source;
const senderFromHeader = body.mail?.commonHeaders?.from?.[0] || senderEmail;
// Process inbound email for each project that has this domain verified
for (const domainRecord of domainRecords) {
signale.info(`[WEBHOOK] Processing inbound email for project: ${domainRecord.project.name}`);
// Find or create a contact for the sender in this project
let contact;
if (senderEmail) {
contact = await ContactService.upsert(
domainRecord.projectId,
senderEmail,
undefined, // No additional data
true, // Subscribe by default for inbound email senders
);
}
// Prepare event data with all inbound email details
const eventData = {
messageId: body.mail?.messageId,
from: senderEmail,
fromHeader: senderFromHeader,
to: recipientEmail,
subject: body.mail?.commonHeaders?.subject,
timestamp: body.mail?.timestamp,
recipients: body.receipt?.recipients,
hasContent: !!body.content,
// Security verdicts
spamVerdict: body.receipt?.spamVerdict?.status,
virusVerdict: body.receipt?.virusVerdict?.status,
spfVerdict: body.receipt?.spfVerdict?.status,
dkimVerdict: body.receipt?.dkimVerdict?.status,
dmarcVerdict: body.receipt?.dmarcVerdict?.status,
// Processing metadata
processingTimeMillis: body.receipt?.processingTimeMillis,
};
// Create the email.received event (this will trigger workflows)
await EventService.trackEvent(
domainRecord.projectId,
'email.received',
contact?.id,
undefined, // No emailId for inbound emails (they're not sent by us)
eventData,
);
signale.success(
`[WEBHOOK] Created email.received event for ${senderEmail}${recipientEmail} (project: ${domainRecord.project.name})`,
);
}
}
return res.status(200).json({success: true, message: 'Inbound email processed'});
} catch (inboundError) {
signale.error('[WEBHOOK] Error processing inbound email:', inboundError);
// Return 200 to acknowledge receipt even if processing failed
return res.status(200).json({success: true, message: 'Error processing inbound email'});
}
}
// Handle outbound email event notifications (existing logic)
const eventType = body.eventType as 'Bounce' | 'Delivery' | 'Open' | 'Complaint' | 'Click';
const messageId = body.mail?.messageId;
@@ -152,29 +262,62 @@ export class Webhooks {
break;
}
case 'Bounce':
signale.warn(`[WEBHOOK] Bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// Unsubscribe contact on bounce
await prisma.contact.update({
where: {id: email.contactId},
data: {subscribed: false},
});
eventData = {
...baseEventData,
bounceType: body.bounce?.bounceType,
bouncedAt: now.toISOString(),
};
case 'Bounce': {
const bounceType = body.bounce?.bounceType;
const isPermanentBounce = bounceType === 'Permanent';
const isTransientBounce = bounceType === 'Transient';
// Send notification about bounce
await NtfyService.notifyEmailBounce(
email.project.name,
email.projectId,
email.contact.email,
body.bounce?.bounceType,
);
if (isPermanentBounce) {
// Hard bounce - counts toward bounce rate and unsubscribes contact
signale.warn(`[WEBHOOK] Permanent bounce received for ${email.contact.email} from ${email.project.name}`);
updateData.status = EmailStatus.BOUNCED;
updateData.bouncedAt = now;
// 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;
}
case 'Complaint':
signale.warn(`[WEBHOOK] Complaint received for ${email.contact.email} from ${email.project.name}`);
@@ -208,8 +351,10 @@ export class Webhooks {
// Track event (this will trigger workflows)
await EventService.trackEvent(email.projectId, eventName, email.contactId, email.id, eventData);
// Check security limits for bounce and complaint events
if (eventType === 'Bounce' || eventType === 'Complaint') {
// Check security limits only for permanent bounces and complaints
// 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);
}
+13 -8
View File
@@ -122,9 +122,13 @@ export async function checkDomainVerifications() {
// Send email notification about domain verified
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(dbDomain.id);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const ttl = 604800; // 7 days
// 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 emails = members.map(m => m.email);
if (emails.length > 0) {
@@ -138,7 +142,6 @@ export async function checkDomainVerifications() {
await Promise.all(
emails.map(email => sendPlatformEmail(email, 'Domain Verified Successfully', template)),
);
await redis.setex(cacheKey, 604800, '1'); // 7 days
}
}
} catch (error) {
@@ -156,12 +159,17 @@ export async function checkDomainVerifications() {
// Send email notification about domain verification failed
try {
// Use SETNX to atomically check and set the flag (prevents race conditions)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(dbDomain.id, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
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) {
const members = await MembershipService.getMembers(dbDomain.projectId);
const emails = members.map(m => m.email);
if (emails.length > 0) {
@@ -175,9 +183,6 @@ export async function checkDomainVerifications() {
await Promise.all(
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) {
+18 -2
View File
@@ -117,10 +117,25 @@ export async function createEmailWorker() {
const fromName = email.fromName || email.project.name;
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
const recipient: {name?: string; email: string} | string = email.toName
? {name: email.toName, email: email.contact.email}
: email.contact.email;
? {name: email.toName, email: recipientEmail}
: recipientEmail;
// Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
@@ -137,6 +152,7 @@ export async function createEmailWorker() {
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: publicHeaders,
tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
});
+21 -36
View File
@@ -18,8 +18,6 @@ import {SegmentService} from '../services/SegmentService.js';
* - For segments without trackMembership: only update counts
*/
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
const segments = await prisma.segment.findMany({
where: {projectId},
@@ -29,45 +27,44 @@ async function processProjectSegments(projectId: string, projectName?: string):
const trackedSegments = 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)
let updatedSegmentCount = 0;
let totalAdded = 0;
let totalRemoved = 0;
if (trackedSegments.length > 0) {
for (const segment of trackedSegments) {
try {
signale.info(
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${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
if (projectName && (result.added > 0 || result.removed > 0)) {
await NtfyService.notifySegmentMembershipComputed(
segment.name,
projectName,
projectId,
result.total,
result.added,
result.removed,
);
// Track segments with actual changes for bundled notification
if (result.added > 0 || result.removed > 0) {
updatedSegmentCount++;
totalAdded += result.added;
totalRemoved += result.removed;
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
// 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)
if (nonTrackedSegments.length > 0) {
try {
await SegmentService.refreshAllMemberCounts(projectId);
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
} catch (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> {
const {projectId} = job.data;
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
try {
if (projectId) {
// Process specific project
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
await processProjectSegments(projectId);
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
} else {
// Process all active projects
const projects = await prisma.project.findMany({
@@ -95,7 +88,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
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
const PROJECT_BATCH_SIZE = 10;
@@ -105,9 +98,7 @@ async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise
await Promise.all(
batch.map(async project => {
try {
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
await processProjectSegments(project.id, project.name);
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// 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));
}
}
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
}
} catch (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) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
});
+78 -11
View File
@@ -366,15 +366,38 @@ export class ActivityService {
...(contactId ? {contactId} : {}),
};
// Build OR conditions to filter by the appropriate timestamp field for each activity type
// This ensures we fetch emails where the specific activity (bounced, sent, etc.) occurred in the date range
const orConditions: Prisma.EmailWhereInput[] = [];
if (!types || types.includes(ActivityType.EMAIL_SENT)) {
orConditions.push({sentAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_DELIVERED)) {
orConditions.push({deliveredAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_OPENED)) {
orConditions.push({openedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_CLICKED)) {
orConditions.push({clickedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_BOUNCED)) {
orConditions.push({bouncedAt: {not: null, ...dateFilter}});
}
if (!types || types.includes(ActivityType.EMAIL_COMPLAINT)) {
orConditions.push({complainedAt: {not: null, ...dateFilter}});
}
// If no OR conditions, return empty (shouldn't happen but defensive)
if (orConditions.length === 0) {
return [];
}
const emails = await prisma.email.findMany({
where: {
...where,
createdAt: cursorTimestamp
? {
...dateFilter,
lt: cursorTimestamp,
}
: dateFilter,
OR: orConditions,
},
orderBy: {createdAt: 'desc'},
take: limit,
@@ -401,6 +424,27 @@ export class ActivityService {
},
});
// Helper function to check if timestamp is within date range
const isInDateRange = (timestamp: Date | null) => {
if (!timestamp) return false;
// Check against date filter
const time = timestamp.getTime();
if (dateFilter.gte) {
const gteTime = dateFilter.gte instanceof Date ? dateFilter.gte.getTime() : new Date(dateFilter.gte).getTime();
if (time < gteTime) return false;
}
if (dateFilter.lte) {
const lteTime = dateFilter.lte instanceof Date ? dateFilter.lte.getTime() : new Date(dateFilter.lte).getTime();
if (time > lteTime) return false;
}
// Check against cursor for pagination
if (cursorTimestamp && time >= cursorTimestamp.getTime()) return false;
return true;
};
// Convert each email into multiple activities based on its state
for (const email of emails) {
const baseMetadata = {
@@ -415,7 +459,7 @@ export class ActivityService {
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({
id: `${email.id}_sent`,
type: ActivityType.EMAIL_SENT,
@@ -426,7 +470,11 @@ export class ActivityService {
});
}
if (email.deliveredAt && (!types || types.includes(ActivityType.EMAIL_DELIVERED))) {
if (
email.deliveredAt &&
(!types || types.includes(ActivityType.EMAIL_DELIVERED)) &&
isInDateRange(email.deliveredAt)
) {
activities.push({
id: `${email.id}_delivered`,
type: ActivityType.EMAIL_DELIVERED,
@@ -437,7 +485,7 @@ export class ActivityService {
});
}
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED))) {
if (email.openedAt && (!types || types.includes(ActivityType.EMAIL_OPENED)) && isInDateRange(email.openedAt)) {
activities.push({
id: `${email.id}_opened`,
type: ActivityType.EMAIL_OPENED,
@@ -452,7 +500,7 @@ export class ActivityService {
}
// 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({
id: `${email.id}_clicked`,
type: ActivityType.EMAIL_CLICKED,
@@ -467,7 +515,7 @@ export class ActivityService {
}
// 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({
id: `${email.id}_bounced`,
type: ActivityType.EMAIL_BOUNCED,
@@ -480,6 +528,25 @@ export class ActivityService {
},
});
}
// Email complaint
if (
email.complainedAt &&
(!types || types.includes(ActivityType.EMAIL_COMPLAINT)) &&
isInDateRange(email.complainedAt)
) {
activities.push({
id: `${email.id}_complaint`,
type: ActivityType.EMAIL_COMPLAINT,
timestamp: email.complainedAt,
contactEmail: email.contact.email,
contactId: email.contactId,
metadata: {
...baseMetadata,
error: email.error,
},
});
}
}
return activities;
+18 -14
View File
@@ -580,8 +580,15 @@ export class BillingLimitService {
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.warningEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
// Use SETNX (SET if Not eXists) to atomically check and set the flag
// 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;
}
@@ -603,11 +610,6 @@ export class BillingLimitService {
});
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) {
signale.error(`[BILLING_LIMIT] Failed to send warning email:`, error);
}
@@ -630,8 +632,15 @@ export class BillingLimitService {
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Billing.limitEmail(projectId, sourceType, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent === '1') {
// Use SETNX (SET if Not eXists) to atomically check and set the flag
// 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;
}
@@ -652,11 +661,6 @@ export class BillingLimitService {
});
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) {
signale.error(`[BILLING_LIMIT] Failed to send limit exceeded email:`, error);
}
+36 -13
View File
@@ -8,7 +8,7 @@ import {HttpException} from '../exceptions/index.js';
import {Keys} from './keys.js';
import {MembershipService} from './MembershipService.js';
import {NtfyService} from './NtfyService.js';
import {getDomainVerificationAttributes, verifyDomain} from './SESService.js';
import {deleteIdentity, getDomainVerificationAttributes, verifyDomain} from './SESService.js';
export class DomainService {
/**
@@ -89,10 +89,13 @@ export class DomainService {
// Send email notification about domain verified
try {
// Check deduplication cache
// Use SETNX to atomically check and set the flag (prevents race conditions)
const cacheKey = Keys.Domain.verifiedEmail(domainId);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
const ttl = 604800; // 7 days
// 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 emails = members.map(m => m.email);
if (emails.length > 0) {
@@ -104,8 +107,6 @@ export class DomainService {
landingUrl: LANDING_URI,
});
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) {
@@ -131,13 +132,17 @@ export class DomainService {
// Send email notification about domain verification failed
try {
// Check deduplication cache (monthly)
// Use SETNX to atomically check and set the flag (prevents race conditions)
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const cacheKey = Keys.Domain.unverifiedEmail(domainId, year, month);
const alreadySent = await redis.get(cacheKey);
if (alreadySent !== '1') {
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) {
const members = await MembershipService.getMembers(updatedDomain.project.id);
const emails = members.map(m => m.email);
if (emails.length > 0) {
@@ -149,10 +154,6 @@ export class DomainService {
landingUrl: LANDING_URI,
});
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) {
@@ -248,6 +249,28 @@ export class DomainService {
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
await NtfyService.notifyDomainRemoved(domainName, domain.project.name, domain.project.id);
+21 -4
View File
@@ -35,6 +35,7 @@ interface SendEmailParams {
campaignId?: string;
workflowExecutionId?: 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
// 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({
where: {id: params.contactId},
select: {subscribed: true},
@@ -242,6 +244,12 @@ export class EmailService {
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({
data: {
projectId: params.projectId,
@@ -251,7 +259,7 @@ export class EmailService {
from: params.from,
fromName: params.fromName,
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,
sourceType,
templateId: params.templateId,
@@ -361,6 +369,15 @@ export class EmailService {
? (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'];
}
// Parse attachments from JSON
const attachments =
email.attachments && Array.isArray(email.attachments)
@@ -376,13 +393,13 @@ export class EmailService {
name: fromName,
email: fromEmail,
},
to: [email.contact.email],
to: [recipientEmail],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
headers: customHeaders,
headers: publicHeaders,
attachments: attachments,
tracking: shouldTrack,
});
@@ -6,6 +6,9 @@ import {redis} from '../database/redis.js';
const DISPOSABLE_DOMAINS_URL =
'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf';
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)
// Known email forwarding/alias services
@@ -39,12 +42,15 @@ const FORWARDING_DOMAINS = new Set([
export class EmailVerificationService {
private static disposableDomainsSet: Set<string> | null = null;
private static personalDomainsSet: Set<string> | null = null;
/**
* Verify an email address
* - Checks if domain exists (DNS A/AAAA records)
* - Checks for MX records
* - Checks for NS records (proves domain exists in DNS)
* - 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 personal/free email providers (Gmail, Hotmail, etc.)
* - Detects forwarding/alias email addresses
* - Suggests corrections for common typos
*/
@@ -56,7 +62,9 @@ export class EmailVerificationService {
isAlias: false,
isTypo: false,
isPlusAddressed: false,
isPersonalEmail: false,
domainExists: false,
hasWebsite: false,
hasMxRecords: false,
reasons: [],
};
@@ -74,6 +82,9 @@ export class EmailVerificationService {
// Check if email is from a disposable domain using GitHub list
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
result.isAlias = this.isForwardingDomain(domain);
@@ -88,35 +99,46 @@ export class EmailVerificationService {
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 {
await dns.resolve(domain, 'A');
result.domainExists = true;
result.hasWebsite = true;
} catch {
// Try AAAA records if A records fail
try {
await dns.resolve(domain, 'AAAA');
result.domainExists = true;
result.hasWebsite = true;
} catch {
result.domainExists = false;
result.valid = 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');
// Domain doesn't have A/AAAA records (no website), but this is OK for email
result.hasWebsite = false;
}
}
@@ -187,4 +209,57 @@ export class EmailVerificationService {
private static isForwardingDomain(domain: string): boolean {
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());
}
}
+38 -15
View File
@@ -211,9 +211,11 @@ export class NtfyService {
const {redis} = await import('../database/redis.js');
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;
}
@@ -223,9 +225,6 @@ export class NtfyService {
`Project "${projectName}" (${projectId}) has security warnings: ${warningText}`,
[NtfyTag.WARNING, NtfyTag.SHIELD],
);
// Throttle for 1 hour
await redis.setex(cacheKey, 3600, '1');
}
/**
@@ -606,9 +605,11 @@ export class NtfyService {
const {redis} = await import('../database/redis.js');
const 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;
}
@@ -617,9 +618,6 @@ export class NtfyService {
`Email usage at ${Math.round(percentage)}% (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId})`,
[NtfyTag.WARNING, NtfyTag.MONEY, NtfyTag.CHART],
);
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
}
// ===== Billing and usage limit notifications =====
@@ -638,9 +636,11 @@ export class NtfyService {
const {redis} = await import('../database/redis.js');
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;
}
@@ -649,9 +649,6 @@ export class NtfyService {
`Email usage limit reached (${usage}/${limit}) for ${sourceType} in project "${projectName}" (${projectId}). Further emails are blocked.`,
[NtfyTag.ERROR, NtfyTag.MONEY, NtfyTag.SKULL],
);
// Throttle for 24 hours
await redis.setex(cacheKey, 86400, '1');
}
/**
@@ -765,6 +762,32 @@ export class NtfyService {
});
}
/**
* Notify about bundled segment membership updates - LOW priority
* Used when multiple segments are updated in a single processing cycle
*/
public static async notifySegmentMembershipBundled(
projectName: string,
projectId: string,
segmentCount: number,
totalAdded: number,
totalRemoved: number,
): Promise<void> {
const changes: string[] = [];
if (totalAdded > 0) changes.push(`+${totalAdded} added`);
if (totalRemoved > 0) changes.push(`-${totalRemoved} removed`);
const changesText = changes.length > 0 ? ` (${changes.join(', ')})` : '';
const message = `${segmentCount} segment${segmentCount > 1 ? 's' : ''} updated in project "${projectName}" (${projectId})${changesText}`;
await this.send({
title: 'Segment Memberships Updated',
message,
priority: NtfyPriority.LOW,
tags: [NtfyTag.CHART],
});
}
/**
* Notify about segment deleted - MIN priority
*/
+8 -1
View File
@@ -8,7 +8,7 @@ import {
DASHBOARD_URI,
SES_CONFIGURATION_SET,
SES_CONFIGURATION_SET_NO_TRACKING,
TRACKING_TOGGLE_ENABLED
TRACKING_TOGGLE_ENABLED,
} 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
* @returns MaxSendRate (emails per second) or null if the call fails
+55 -22
View File
@@ -25,10 +25,17 @@ const SECURITY_THRESHOLDS = {
BOUNCE_ALLTIME_CRITICAL: 8,
// Complaint rate thresholds (spam reports)
COMPLAINT_7DAY_WARNING: 0.05,
COMPLAINT_7DAY_CRITICAL: 0.1,
COMPLAINT_ALLTIME_WARNING: 0.02,
COMPLAINT_ALLTIME_CRITICAL: 0.08,
COMPLAINT_7DAY_WARNING: 0.075,
COMPLAINT_7DAY_CRITICAL: 0.15,
COMPLAINT_ALLTIME_WARNING: 0.03,
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;
interface RateData {
@@ -280,51 +287,77 @@ export class SecurityService {
// Check 7-day bounce rate (only if 7-day volume is sufficient)
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(
`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(
`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)
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(
`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(
`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)
if (hasMinimumVolumeAllTime) {
// Check all-time bounce rate
if (allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL) {
// Check all-time bounce rate - requires BOTH rate AND absolute count
if (
allTime.bounceRate >= SECURITY_THRESHOLDS.BOUNCE_ALLTIME_CRITICAL &&
allTime.bounces >= SECURITY_THRESHOLDS.MIN_BOUNCES_FOR_CRITICAL
) {
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(
`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
if (allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL) {
// Check all-time complaint rate - requires BOTH rate AND absolute count
if (
allTime.complaintRate >= SECURITY_THRESHOLDS.COMPLAINT_ALLTIME_CRITICAL &&
allTime.complaints >= SECURITY_THRESHOLDS.MIN_COMPLAINTS_FOR_CRITICAL
) {
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(
`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)`,
);
}
}
+103 -1
View File
@@ -584,8 +584,10 @@ export class SegmentService {
'exists',
'notExists',
'within',
'olderThan',
'triggered',
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
];
@@ -604,7 +606,9 @@ export class SegmentService {
'greaterThanOrEqual',
'lessThanOrEqual',
'within',
'olderThan',
'triggeredWithin',
'triggeredOlderThan',
];
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
@@ -612,7 +616,7 @@ export class SegmentService {
}
// 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)`);
}
}
@@ -729,6 +733,20 @@ export class SegmentService {
// Use ISO string for lexicographic comparison in JSON
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:
throw new HttpException(400, `Unsupported operator for JSON field: ${operator}`);
}
@@ -822,6 +840,18 @@ export class SegmentService {
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:
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':
// Contact has never triggered this event
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':
// Contact has never had this email activity
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(
step: WorkflowStepWithTemplate,
execution: WorkflowExecutionWithRelations,
stepExecution: WorkflowStepExecution,
_config: StepConfig,
config: StepConfig,
): Promise<StepResult> {
if (!step.template) {
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
const contact = execution.contact;
const contactData =
@@ -558,10 +565,20 @@ export class WorkflowExecutionService {
const renderedSubject = this.renderTemplate(step.template.subject, 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
const email = await EmailService.sendWorkflowEmail({
projectId: execution.workflow.projectId,
contactId: contact.id,
contactId: recipientContactId,
workflowExecutionId: execution.id,
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
templateId: step.template.id,
@@ -570,11 +587,15 @@ export class WorkflowExecutionService {
from: step.template.from,
fromName: step.template.fromName || undefined,
replyTo: step.template.replyTo || undefined,
// Pass custom recipient email if specified
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
});
return {
emailId: email.id,
sentAt: email.createdAt,
recipientType: recipientConfig.type,
recipientEmail,
};
}
+1 -1
View File
@@ -27,7 +27,7 @@
"@tiptap/starter-kit": "^3.11.0",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.10",
"next": "^16.1.5",
"next-seo": "^6.6.0",
"react": "19.2.3",
"react-dom": "19.2.3",
@@ -2,15 +2,16 @@ import {
AlertCircle,
AlertTriangle,
CheckCircle,
Forward,
Info,
Mail,
Server,
Shield,
Trash2,
User,
XCircle,
Forward,
} from 'lucide-react';
import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification';
import type {EmailVerificationResult as VerificationResult} from '@plunk/types';
interface EmailVerificationResultProps {
result: VerificationResult;
@@ -52,7 +53,7 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
<Server className="h-5 w-5 text-neutral-600" />
<div>
<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>
{result.domainExists ? (
@@ -78,6 +79,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)}
</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 */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
@@ -94,6 +111,22 @@ export function EmailVerificationResult({result}: EmailVerificationResultProps)
)}
</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 */}
<div className="flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3">
+1 -12
View File
@@ -1,15 +1,4 @@
export interface EmailVerificationResult {
email: string;
valid: boolean;
isDisposable: boolean;
isAlias: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
suggestedEmail?: string;
reasons: string[];
}
import type {EmailVerificationResult} from '@plunk/types';
export async function verifyEmail(email: string): Promise<EmailVerificationResult> {
const response = await fetch('/api/verify-email', {
+6 -1
View File
@@ -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
@@ -29,6 +29,11 @@ export const EMAIL_VERIFICATION_FEATURES = [
description: 'Identify temporary email addresses that are often used for spam or fake signups.',
icon: Mail,
},
{
title: 'Personal Email Detection',
description: 'Detect personal/free email providers like Gmail, Hotmail, Yahoo for B2B validation.',
icon: User,
},
{
title: 'Plus Addressing',
description: 'Detect plus-addressed emails ([email protected]) which can be useful for tracking.',
+5 -13
View File
@@ -1,24 +1,16 @@
import type {NextApiRequest, NextApiResponse} from 'next';
import type {EmailVerificationResult} from '@plunk/types';
import {UtilitySchemas} from '@plunk/shared';
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 {
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
if (req.method !== 'POST') {
return res.status(405).json({error: 'Method not allowed'});
@@ -4,7 +4,8 @@ import {DASHBOARD_URI} from '../../lib/constants';
import React, {useState} from 'react';
import {NextSeo} from 'next-seo';
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 {Button, Input} from '@plunk/ui';
import {EMAIL_VERIFICATION_FEATURES} from '../../lib/toolsContent';
+1 -1
View File
@@ -41,7 +41,7 @@
"framer-motion": "^12.23.24",
"juice": "^11.0.3",
"lucide-react": "^0.553.0",
"next": "^16.0.10",
"next": "^16.1.5",
"next-seo": "^6.6.0",
"nuqs": "^2.7.3",
"react": "19.2.3",
+34 -5
View File
@@ -34,7 +34,8 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
setIsLoadingMore(true);
} else {
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);
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()}`);
if (cursor) {
// Append to existing activities
// Append to existing activities (pagination)
setActivities(prev => [...prev, ...result.data]);
} else {
// Replace activities
setActivities(result.data);
// Smart merge: preserve existing activity objects by ID to maintain component state
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);
@@ -95,7 +110,21 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
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) {
console.error('Error fetching upcoming activities:', err);
// Don't set error state for upcoming - just fail silently
+26 -1
View File
@@ -11,6 +11,7 @@ import {
Eye,
MousePointerClick,
Send,
ShieldAlert,
Workflow,
XCircle,
Zap,
@@ -95,7 +96,14 @@ function getUpcomingTime(date: Date): string {
* Check if an activity is an email activity
*/
function isEmailActivity(type: string): boolean {
return ['email.sent', 'email.delivered', 'email.opened', 'email.clicked', 'email.bounced'].includes(type);
return [
'email.sent',
'email.delivered',
'email.opened',
'email.clicked',
'email.bounced',
'email.complaint',
].includes(type);
}
interface ActivityItemProps {
@@ -226,6 +234,23 @@ function getActivityConfig(activity: Activity): ActivityConfig {
},
};
case 'email.complaint':
return {
icon: ShieldAlert,
color: 'text-red-600',
bgColor: 'bg-red-100',
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
description: metadata.campaignName
? `Campaign: ${String(metadata.campaignName)}`
: metadata.workflowName
? `Workflow: ${String(metadata.workflowName)}`
: 'Recipient marked as spam',
badge: {
label: 'Complaint',
variant: 'destructive',
},
};
case 'workflow.started':
return {
icon: Workflow,
@@ -1,9 +1,11 @@
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
import {useUser} from '../lib/hooks/useUser';
import {WIKI_URI} from '../lib/constants';
import {network} from '../lib/network';
import {
Activity,
BarChart3,
BookOpen,
ChevronDown,
FileText,
Layers,
@@ -246,6 +248,16 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
{/* Settings & User Menu */}
<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
href="/settings"
onClick={() => setShowMobileMenu(false)}
+301 -125
View File
@@ -3,8 +3,6 @@ import {useForm} from 'react-hook-form';
import {zodResolver} from '@hookform/resolvers/zod';
import {DomainSchemas} from '@plunk/shared';
import {
Alert,
AlertDescription,
Badge,
Button,
Card,
@@ -22,7 +20,8 @@ import {
Input,
} from '@plunk/ui';
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';
interface DomainsSettingsProps {
@@ -34,6 +33,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
const {addDomain} = useAddDomain();
const {checkVerification} = useCheckDomainVerification();
const {removeDomain} = useRemoveDomain();
const {data: config} = useConfig();
const [successMessage, setSuccessMessage] = 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 [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [domainToRemove, setDomainToRemove] = useState<{id: string; name: string} | null>(null);
const [expandedDomains, setExpandedDomains] = useState<{[key: string]: boolean}>({});
const form = useForm<{domain: string}>({
resolver: zodResolver(DomainSchemas.create.omit({projectId: true})),
@@ -86,6 +87,28 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
return () => clearInterval(interval);
}, [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) => {
if (type === 'success') {
setSuccessMessage(message);
@@ -113,6 +136,11 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
},
}));
setSelectedDomain(newDomain.id);
// Auto-expand newly added domain
setExpandedDomains(prev => ({
...prev,
[newDomain.id]: true,
}));
}
await mutateDomains();
@@ -322,20 +350,42 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</div>
</div>
{!status.verified && Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
<div className="mt-4 pt-4 border-t border-neutral-200">
<Alert>
<AlertDescription>
<div className="space-y-4">
<div>
<p className="font-medium text-sm mb-1">DNS Configuration Required</p>
<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.
{Array.isArray(status.tokens) && (status.tokens as string[]).length > 0 && (
<div className="mt-3">
<button
onClick={() =>
setExpandedDomains(prev => ({
...prev,
[domain.id]: !prev[domain.id],
}))
}
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>
</div>
)}
{/* DNS Records Table */}
<div className="overflow-x-auto">
<table className="w-full text-xs border-collapse">
<thead>
@@ -401,120 +451,246 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
</td>
</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">
&quot;v=spf1 include:amazonses.com ~all&quot;
</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 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>
</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">
&quot;v=spf1 include:amazonses.com ~all&quot;
</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>
@@ -27,12 +27,14 @@ const STANDARD_OPERATORS: {value: SegmentFilterOperator; label: string}[] = [
{value: 'lessThanOrEqual', label: 'Less than or equal to'},
{value: 'exists', label: 'Exists'},
{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}[] = [
{value: 'triggered', label: 'Ever occurred'},
{value: 'triggeredWithin', label: 'Occurred within'},
{value: 'triggeredOlderThan', label: 'Occurred over (time) ago'},
{value: 'notTriggered', label: 'Never occurred'},
];
@@ -172,6 +174,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
'exists',
'notExists',
'within',
'olderThan',
].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 needsUnit = ['within', 'triggeredWithin'].includes(filter.operator);
const needsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator);
// Get field type from available fields
const fieldOption = useMemo(
@@ -217,6 +220,7 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
'exists',
'notExists',
'within',
'olderThan',
].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 newFieldType = selectedField?.type || 'string';
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
let newOperator = filter.operator;
@@ -267,7 +273,9 @@ const FilterRow = memo(function FilterRow({filter, onChange, onRemove, available
newUnit = undefined;
// 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) {
// Convert numeric value back to appropriate type for the field
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
const oldNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(oldOperator);
const newNeedsValue = !['exists', 'notExists', 'triggered', 'notTriggered'].includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin'].includes(oldOperator);
const newNeedsUnit = ['within', 'triggeredWithin'].includes(newOperator);
const oldNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
oldOperator,
);
const newNeedsUnit = ['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(
newOperator,
);
const updatedFilter: SegmentFilter = {...filter, operator: newOperator};
+80 -5
View File
@@ -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
@@ -412,6 +434,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-yes`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: 'Yes',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'yes'}),
@@ -424,6 +447,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-no`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: 'No',
onClick: () => setAddStepContext({fromStepId: step.id, branch: 'no'}),
@@ -437,6 +461,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add`,
type: 'addStep',
position: {x: 0, y: 0},
draggable: false,
data: {
label: '',
onClick: () => setAddStepContext({fromStepId: step.id}),
@@ -510,7 +535,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-yes-edge`,
source: step.id,
target: `${step.id}-add-yes`,
type: 'smoothstep',
type: 'straight',
animated: false,
label: 'Yes',
labelStyle: {fill: '#16a34a', fontWeight: 600, fontSize: 12},
@@ -527,7 +552,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-no-edge`,
source: step.id,
target: `${step.id}-add-no`,
type: 'smoothstep',
type: 'straight',
animated: false,
label: 'No',
labelStyle: {fill: '#dc2626', fontWeight: 600, fontSize: 12},
@@ -544,7 +569,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
id: `${step.id}-add-edge`,
source: step.id,
target: `${step.id}-add`,
type: 'smoothstep',
type: 'straight',
animated: false,
style: {stroke: '#94a3b8', strokeWidth: 2.5, strokeDasharray: '5,5'},
markerEnd: {type: MarkerType.ArrowClosed, color: '#94a3b8', width: 22, height: 22},
@@ -565,6 +590,56 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes);
const [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
useEffect(() => {
setNodes(layoutedNodes);
@@ -727,7 +802,7 @@ export function WorkflowBuilder({workflowId, steps, onUpdate}: WorkflowBuilderPr
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onNodesChange={handleNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
fitView
+3
View File
@@ -19,6 +19,9 @@ export interface ConfigResponse {
ports: {secure: number; submission: number} | null;
};
};
aws: {
sesRegion: string;
};
}
/**
+14 -11
View File
@@ -5,6 +5,7 @@ import React, {useEffect} from 'react';
import {Toaster} from 'sonner';
import {SWRConfig} from 'swr';
import {DefaultSeo} from 'next-seo';
import {NuqsAdapter} from 'nuqs/adapters/next/pages';
import {Loader} from '@plunk/ui';
import {ActiveProjectProvider} from '../lib/contexts/ActiveProjectProvider';
import {useProjects} from '../lib/hooks/useProject';
@@ -103,17 +104,19 @@ function ProjectGuard({children}: {children: React.ReactNode}) {
*/
export default function WithProviders(props: AppProps) {
return (
<SWRConfig
value={{
fetcher: (url: string) => network.fetch('GET', url),
shouldRetryOnError: false,
}}
>
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
<ActiveProjectProvider>
<Root {...props} />
</ActiveProjectProvider>
</SWRConfig>
<NuqsAdapter>
<SWRConfig
value={{
fetcher: (url: string) => network.fetch('GET', url),
shouldRetryOnError: false,
}}
>
<DefaultSeo titleTemplate="%s | Plunk" defaultTitle="Plunk | Email Platform Dashboard" />
<ActiveProjectProvider>
<Root {...props} />
</ActiveProjectProvider>
</SWRConfig>
</NuqsAdapter>
);
}
+6 -4
View File
@@ -14,7 +14,7 @@ import {DashboardLayout} from '../../components/DashboardLayout';
import {ActivityFeed} from '../../components/ActivityFeed';
import {Eye, MousePointerClick, Send, Zap} from 'lucide-react';
import {NextSeo} from 'next-seo';
import {useState} from 'react';
import {useQueryState, parseAsString} from 'nuqs';
import useSWR from 'swr';
interface ActivityStats {
@@ -28,8 +28,8 @@ interface ActivityStats {
}
export default function ActivityPage() {
const [typeFilter, setTypeFilter] = useState<string>('ALL');
const [dateRange, setDateRange] = useState<string>('30');
const [typeFilter, setTypeFilter] = useQueryState('type', parseAsString.withDefault('ALL'));
const [dateRange, setDateRange] = useQueryState('days', parseAsString.withDefault('30'));
// Fetch activity stats
const {data: stats} = useSWR<ActivityStats>(`/activity/stats`, {
@@ -119,12 +119,14 @@ export default function ActivityPage() {
<SelectContent>
<SelectItem value="ALL">All Activity Types</SelectItem>
<SelectItem value="event.triggered">Events</SelectItem>
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced">
<SelectItem value="email.sent,email.delivered,email.opened,email.clicked,email.bounced,email.complaint">
Emails
</SelectItem>
<SelectItem value="email.sent">Emails Sent</SelectItem>
<SelectItem value="email.opened">Emails Opened</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>
</SelectContent>
</Select>
+15
View File
@@ -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,
},
};
};
+1 -1
View File
@@ -118,7 +118,7 @@ export default function TemplateEditorPage() {
}
};
if (!template) {
if (!template || Object.keys(editedTemplate).length === 0) {
return (
<DashboardLayout>
<div className="flex items-center justify-center min-h-[400px]">
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 |
|-----|-------------|
| 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 -1
View File
@@ -1,3 +1,3 @@
{
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization"]
"pages": ["list-hygiene", "verifying-domains", "tracking", "api-keys", "localization", "webhooks"]
}
+153
View File
@@ -0,0 +1,153 @@
---
title: Webhooks
description: Send real-time event data from Plunk to your own application using webhooks
icon: Webhook
---
Plunk can send real-time HTTP requests to your application when specific events occur, such as email bounces, spam complaints, or custom events. This is done by creating a [workflow](/concepts/workflows) that uses the **Webhook** step to forward event data to your own endpoint.
## How it works
Webhooks in Plunk are powered by the workflow system. The basic flow is:
1. An event occurs in Plunk (e.g. an email bounces, a contact subscribes, or a custom event is tracked)
2. A workflow is triggered by that event
3. The workflow executes a **Webhook** step, sending an HTTP request to your URL with relevant data
This means you can receive notifications for any event Plunk tracks, including both system events and your own custom events.
## Internal events
Plunk automatically tracks a set of internal events that you can use as workflow triggers. These events cannot be manually tracked via the API — they are generated by the system.
### Email events
| Event | Description |
|-------|-------------|
| `email.sent` | An email was successfully sent |
| `email.delivery` | An email was delivered to the recipient |
| `email.open` | A contact opened an email for the first time |
| `email.click` | A contact clicked a link in an email for the first time |
| `email.bounce` | An email bounced (hard or soft bounce) |
| `email.complaint` | A contact marked an email as spam |
### Contact events
| Event | Description |
|-------|-------------|
| `contact.subscribed` | A contact's subscription status changed to subscribed |
| `contact.unsubscribed` | A contact's subscription status changed to unsubscribed |
### Segment events
| Event | Description |
|-------|-------------|
| `segment.<name>.entry` | A contact entered a segment |
| `segment.<name>.exit` | A contact exited a segment |
<Callout
title="Segment event names"
variant="idea">
Segment events use a slugified version of the segment name. For example, a segment called "VIP Users" would produce the events `segment.vip-users.entry` and `segment.vip-users.exit`.
</Callout>
## Setting up a webhook
<div className='fd-steps [&_h3]:fd-step'>
### Create the workflow
Navigate to the **Workflows** section in the dashboard and create a new workflow. Choose the event you want to listen for as the trigger. For example, to receive notifications when an email bounces, use `email.bounce` as the trigger event.
### Add a Webhook step
After the trigger, add a **Webhook** step and configure it:
- **URL**: The endpoint on your server that will receive the webhook (e.g. `https://api.example.com/webhooks/plunk`)
- **Method**: The HTTP method to use. Defaults to `POST`, which is recommended for most use cases.
- **Headers** (optional): Custom headers to include in the request, provided as JSON. This is useful for authentication.
```json
{
"Authorization": "Bearer your-secret-token"
}
```
### Enable the workflow
Once configured, enable the workflow. It will start sending webhook requests whenever the trigger event occurs.
</div>
## Webhook payload
When using the default payload (no custom body configured), Plunk sends a JSON request with the following structure:
```json
{
"contact": {
"email": "[email protected]",
"subscribed": true,
"data": {
"name": "John",
"plan": "pro"
}
},
"workflow": {
"id": "wf_abc123",
"name": "Bounce Notifications"
},
"execution": {
"id": "exec_xyz789",
"startedAt": "2025-01-15T10:30:00.000Z"
},
"event": {
"subject": "Welcome to Plunk",
"from": "[email protected]",
"bounceType": "Permanent"
}
}
```
The `event` field contains the data associated with the event that triggered the workflow. The exact contents depend on the event type.
### Event data by type
The `event` field varies depending on which event triggered the workflow:
| Event | Fields in `event` |
|-------|-------------------|
| `email.sent` | `subject`, `from`, `messageId`, `templateId`, `campaignId`, `sourceType` |
| `email.open` | `subject`, `from`, `openedAt`, `isFirstOpen` |
| `email.click` | `subject`, `from`, `clickedAt`, `clicks`, `isFirstClick` |
| `email.bounce` | `subject`, `from`, `bounceType`, `bouncedAt` |
| `email.complaint` | `subject`, `from`, `complainedAt` |
| Custom events | Whatever data you passed when tracking the event |
## Common use cases
### Bounce and complaint monitoring
Create a workflow triggered by `email.bounce` or `email.complaint` to forward these events to your application. This allows you to keep your own database in sync with Plunk's contact statuses.
You can use additional workflow steps before the webhook to add logic:
- **Condition**: Only send the webhook for hard bounces by checking the `bounceType` field
- **Delay**: Add a short delay to batch-process related events
- **Update Contact**: Mark the contact with metadata before sending the webhook
### Syncing unsubscribes
Trigger a workflow on `contact.unsubscribed` to notify your application when a contact opts out. This is useful for keeping subscription status synchronized across multiple systems.
### Custom event forwarding
If you track custom events in Plunk (e.g. `user.signup`, `order.completed`), you can forward those same events to other services via webhooks. This turns Plunk into an event router — track once, distribute to multiple endpoints.
## Adding conditions and delays
Since webhooks are part of the workflow system, you can combine them with other step types for more advanced setups:
- Use a **Condition** step to only fire the webhook when certain criteria are met (e.g. only notify for contacts on a specific plan)
- Use a **Wait for Event** step to wait for a follow-up event before sending the webhook (e.g. wait to see if a bounced contact re-subscribes)
- Use a **Delay** step to add a time buffer before the webhook fires
+22 -3
View File
@@ -596,7 +596,7 @@
"post": {
"tags": ["Public API"],
"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",
"requestBody": {
"required": true,
@@ -676,9 +676,17 @@
"type": "boolean",
"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": {
"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": {
"type": "boolean",
@@ -705,7 +713,9 @@
"isAlias",
"isTypo",
"isPlusAddressed",
"isPersonalEmail",
"domainExists",
"hasWebsite",
"hasMxRecords",
"reasons"
]
@@ -724,7 +734,9 @@
"isAlias": false,
"isTypo": false,
"isPlusAddressed": false,
"isPersonalEmail": true,
"domainExists": true,
"hasWebsite": true,
"hasMxRecords": true,
"reasons": ["Email appears to be valid"]
}
@@ -741,10 +753,15 @@
"isAlias": false,
"isTypo": true,
"isPlusAddressed": false,
"isPersonalEmail": false,
"domainExists": false,
"hasWebsite": false,
"hasMxRecords": false,
"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,
"isTypo": false,
"isPlusAddressed": false,
"isPersonalEmail": false,
"domainExists": true,
"hasWebsite": true,
"hasMxRecords": true,
"reasons": ["Email appears to be valid"]
}
+1 -1
View File
@@ -21,7 +21,7 @@
"fumadocs-openapi": "^10.0.11",
"fumadocs-ui": "16.0.8",
"lucide-react": "^0.553.0",
"next": "^16.0.10",
"next": "^16.1.5",
"next-sitemap": "^4.2.3",
"react": "^19.2.3",
"react-dom": "^19.2.3",
+2 -2
View File
@@ -202,8 +202,8 @@ services:
ports:
# SMTP ports (for email relay)
- "465:465" # SMTPS (implicit TLS)
- "587:587" # SMTP Submission (STARTTLS)
- "${PORT_SECURE:-465}:465" # SMTPS (implicit TLS)
- "${PORT_SUBMISSION:-587}:587" # SMTP Submission (STARTTLS)
# Optional: Expose individual service ports for debugging
# - "8080:8080" # API
# - "3000:3000" # Web
+36 -7
View File
@@ -25,11 +25,32 @@ PLACEHOLDER_WIKI="https://next-wiki.useplunk.com"
# Output manifest file
MANIFEST_FILE="$APP_DIR/.next/url-manifest.txt"
# Find all files that contain placeholder URLs and save to manifest
# Search in .next directory for relevant file types
find "$APP_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 > "$MANIFEST_FILE" || true
# At build time, Next.js creates:
# - .next/static/* - Static assets (copied separately to standalone in Dockerfile)
# - .next/standalone/apps/<app>/.next/* - Server files
#
# 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
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 " 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"
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 ' ')
echo "✅ Generated sitemap manifest for $APP_NAME: $SITEMAP_COUNT files"
+23 -18
View File
@@ -37,21 +37,24 @@ replace_urls_in_app() {
echo " 📊 Processing $file_count files from manifest"
if [ "$file_count" -gt 0 ]; then
# Process files using xargs for better performance
# Use -P 4 to process up to 4 files in parallel
# This significantly speeds up processing of many files
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
'
# Process files line by line
# The manifest now contains RELATIVE paths (e.g., .next/static/chunks/abc.js)
# We just need to prepend the app_dir
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
echo " ️ No files found with placeholder URLs"
fi
@@ -61,14 +64,16 @@ replace_urls_in_app() {
local sitemap_manifest="$app_dir/.next/sitemap-manifest.txt"
if [ -f "$sitemap_manifest" ]; then
while IFS= read -r sitemap_file; do
if [ -f "$sitemap_file" ]; then
local rel_path="${sitemap_file#$app_dir/}"
while IFS= read -r rel_path; do
# Manifest contains relative paths, just prepend app_dir
runtime_sitemap_path="$app_dir/$rel_path"
if [ -f "$runtime_sitemap_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" \
"$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
done < "$sitemap_manifest"
else
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "plunk",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"scripts": {
"build": "turbo build",
@@ -10,6 +10,7 @@
"clean": "turbo clean --concurrency 20 && rimraf node_modules .turbo",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"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:watch": "vitest --watch",
"test:ui": "vitest --ui",
+9
View File
@@ -5,6 +5,11 @@ import nlTranslations from './locales/nl.json' with {type: 'json'};
import frTranslations from './locales/fr.json' with {type: 'json'};
import deTranslations from './locales/de.json' with {type: 'json'};
import hiTranslations from './locales/hi.json' with {type: 'json'};
import ptTranslations from './locales/pt.json' with {type: 'json'};
import bgTranslations from './locales/bg.json' with {type: 'json'};
import csTranslations from './locales/cs.json' with {type: 'json'};
import plTranslations from './locales/pl.json' with {type: 'json'};
export {
SUPPORTED_LANGUAGES,
DEFAULT_LANGUAGE,
@@ -34,6 +39,10 @@ const translationsMap: Record<string, Translations> = {
de: deTranslations,
fr: frTranslations,
hi: hiTranslations,
pt: ptTranslations,
bg: bgTranslations,
cs: csTranslations,
pl: plTranslations,
};
// In-memory cache for loaded translations
+4
View File
@@ -11,6 +11,10 @@ export const SUPPORTED_LANGUAGES: Language[] = [
{code: 'fr', name: 'French', nativeName: 'Français', flag: '🇫🇷'},
{code: 'hi', name: 'Hindi', nativeName: 'हिंदी', flag: '🇮🇳'},
{code: 'de', name: 'German', nativeName: 'Deutsch', flag: '🇩🇪'},
{code: 'pt', name: 'Portuguese', nativeName: 'Português', flag: '🇧🇷'},
{code: 'bg', name: 'Bulgarian', nativeName: 'Български', flag: '🇧🇬'},
{code: 'cs', name: 'Czech', nativeName: 'Čeština', flag: '🇨🇿'},
{code: 'pl', name: 'Polish', nativeName: 'Polski', flag: '🇵🇱'},
];
export const DEFAULT_LANGUAGE = 'en';
+45
View File
@@ -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": "променете настройките"
}
}
}
+45
View File
@@ -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"
}
}
}
+45
View File
@@ -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"
}
}
}
+45
View File
@@ -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"
}
}
}
+23
View File
@@ -101,8 +101,10 @@ const segmentFilterSchema = z.object({
'exists',
'notExists',
'within',
'olderThan',
'triggered',
'triggeredWithin',
'triggeredOlderThan',
'notTriggered',
]),
value: z.any().optional(),
@@ -206,6 +208,27 @@ export const WorkflowSchemas = {
};
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
.object({
amount: z.number().positive(),
+1
View File
@@ -12,6 +12,7 @@ export enum ActivityType {
EMAIL_OPENED = 'email.opened',
EMAIL_CLICKED = 'email.clicked',
EMAIL_BOUNCED = 'email.bounced',
EMAIL_COMPLAINT = 'email.complaint',
CAMPAIGN_SENT = 'campaign.sent',
CAMPAIGN_SCHEDULED = 'campaign.scheduled',
WORKFLOW_STARTED = 'workflow.started',
+4 -2
View File
@@ -12,8 +12,10 @@ export interface EmailVerificationResult {
isAlias: boolean;
isTypo: boolean;
isPlusAddressed: boolean;
domainExists: boolean;
hasMxRecords: boolean;
isPersonalEmail: boolean; // Email is from a personal/free provider (Gmail, Hotmail, etc.)
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;
reasons: string[];
}
+3
View File
@@ -26,3 +26,6 @@ export * from './segments/index.js';
// Security types
export * from './security/index.js';
// Workflow types
export * from './workflows/index.js';
+2
View File
@@ -16,9 +16,11 @@ export type SegmentFilterOperator =
| 'exists'
| 'notExists'
| 'within'
| 'olderThan' // More than X time ago (for date fields)
// Event-based operators
| 'triggered' // Event/email activity occurred (any time)
| 'triggeredWithin' // Event/email activity occurred within timeframe
| 'triggeredOlderThan' // Event/email activity occurred more than X time ago
| 'notTriggered'; // Event/email activity never occurred
export type SegmentFilterLogic = 'AND' | 'OR';
+33
View File
@@ -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
View File
@@ -34,6 +34,7 @@
"dependsOn": ["^start"],
"env": [
"NODE_ENV",
"PORT",
"JWT_SECRET",
"DATABASE_URL",
"DIRECT_DATABASE_URL",
@@ -53,6 +54,7 @@
"AWS_SES_SECRET_ACCESS_KEY",
"SES_CONFIGURATION_SET",
"SES_CONFIGURATION_SET_NO_TRACKING",
"EMAIL_RATE_LIMIT_PER_SECOND",
"GITHUB_OAUTH_CLIENT",
"GITHUB_OAUTH_SECRET",
"GOOGLE_OAUTH_CLIENT",
@@ -67,7 +69,15 @@
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI",
"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*"]
},
@@ -84,6 +94,7 @@
"persistent": true,
"env": [
"NODE_ENV",
"PORT",
"JWT_SECRET",
"DATABASE_URL",
"DIRECT_DATABASE_URL",
@@ -103,6 +114,7 @@
"AWS_SES_SECRET_ACCESS_KEY",
"SES_CONFIGURATION_SET",
"SES_CONFIGURATION_SET_NO_TRACKING",
"EMAIL_RATE_LIMIT_PER_SECOND",
"GITHUB_OAUTH_CLIENT",
"GITHUB_OAUTH_SECRET",
"GOOGLE_OAUTH_CLIENT",
@@ -117,7 +129,15 @@
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI",
"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": {
@@ -125,6 +145,7 @@
"persistent": true,
"env": [
"NODE_ENV",
"PORT",
"JWT_SECRET",
"DATABASE_URL",
"DIRECT_DATABASE_URL",
@@ -144,6 +165,7 @@
"AWS_SES_SECRET_ACCESS_KEY",
"SES_CONFIGURATION_SET",
"SES_CONFIGURATION_SET_NO_TRACKING",
"EMAIL_RATE_LIMIT_PER_SECOND",
"GITHUB_OAUTH_CLIENT",
"GITHUB_OAUTH_SECRET",
"GOOGLE_OAUTH_CLIENT",
@@ -158,7 +180,15 @@
"NEXT_PUBLIC_DASHBOARD_URI",
"NEXT_PUBLIC_WIKI_URI",
"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": {
+105 -78
View File
@@ -2821,10 +2821,10 @@ __metadata:
languageName: node
linkType: hard
"@next/env@npm:16.0.10":
version: 16.0.10
resolution: "@next/env@npm:16.0.10"
checksum: 10c0/6f2b5ba37733a49513ab3117c06f037fa962b4022fe2b4ba7801f830ab15974ad87ad8b47b6464fa5fcb3e9a3247b25f657ec97ae6eed1156b5736c6388246db
"@next/env@npm:16.1.6":
version: 16.1.6
resolution: "@next/env@npm:16.1.6"
checksum: 10c0/ed7023edb94b9b2e5da3f9c99d08b614da9757c1edd0ecec792fce4d336b4f0c64db1a84955e07cfbd848b9e61c4118fff28f4098cd7b0a7f97814a90565ebe6
languageName: node
linkType: hard
@@ -2851,9 +2851,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-arm64@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-darwin-arm64@npm:16.0.10"
"@next/swc-darwin-arm64@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-darwin-arm64@npm:16.1.6"
conditions: os=darwin & cpu=arm64
languageName: node
linkType: hard
@@ -2865,9 +2865,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-darwin-x64@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-darwin-x64@npm:16.0.10"
"@next/swc-darwin-x64@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-darwin-x64@npm:16.1.6"
conditions: os=darwin & cpu=x64
languageName: node
linkType: hard
@@ -2879,9 +2879,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-gnu@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-linux-arm64-gnu@npm:16.0.10"
"@next/swc-linux-arm64-gnu@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-linux-arm64-gnu@npm:16.1.6"
conditions: os=linux & cpu=arm64 & libc=glibc
languageName: node
linkType: hard
@@ -2893,9 +2893,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-arm64-musl@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-linux-arm64-musl@npm:16.0.10"
"@next/swc-linux-arm64-musl@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-linux-arm64-musl@npm:16.1.6"
conditions: os=linux & cpu=arm64 & libc=musl
languageName: node
linkType: hard
@@ -2907,9 +2907,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-gnu@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-linux-x64-gnu@npm:16.0.10"
"@next/swc-linux-x64-gnu@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-linux-x64-gnu@npm:16.1.6"
conditions: os=linux & cpu=x64 & libc=glibc
languageName: node
linkType: hard
@@ -2921,9 +2921,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-linux-x64-musl@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-linux-x64-musl@npm:16.0.10"
"@next/swc-linux-x64-musl@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-linux-x64-musl@npm:16.1.6"
conditions: os=linux & cpu=x64 & libc=musl
languageName: node
linkType: hard
@@ -2935,9 +2935,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-arm64-msvc@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-win32-arm64-msvc@npm:16.0.10"
"@next/swc-win32-arm64-msvc@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-win32-arm64-msvc@npm:16.1.6"
conditions: os=win32 & cpu=arm64
languageName: node
linkType: hard
@@ -2949,9 +2949,9 @@ __metadata:
languageName: node
linkType: hard
"@next/swc-win32-x64-msvc@npm:16.0.10":
version: 16.0.10
resolution: "@next/swc-win32-x64-msvc@npm:16.0.10"
"@next/swc-win32-x64-msvc@npm:16.1.6":
version: 16.1.6
resolution: "@next/swc-win32-x64-msvc@npm:16.1.6"
conditions: os=win32 & cpu=x64
languageName: node
linkType: hard
@@ -8182,6 +8182,15 @@ __metadata:
languageName: node
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":
version: 2.9.6
resolution: "baseline-browser-mapping@npm:2.9.6"
@@ -8308,7 +8317,7 @@ __metadata:
languageName: node
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
resolution: "browserslist@npm:4.28.1"
dependencies:
@@ -9504,9 +9513,9 @@ __metadata:
linkType: hard
"diff@npm:^4.0.1":
version: 4.0.2
resolution: "diff@npm:4.0.2"
checksum: 10c0/81b91f9d39c4eaca068eb0c1eb0e4afbdc5bb2941d197f513dd596b820b956fef43485876226d65d497bebc15666aa2aa82c679e84f65d5f2bfbf14ee46e32c1
version: 4.0.4
resolution: "diff@npm:4.0.4"
checksum: 10c0/855fb70b093d1d9643ddc12ea76dca90dc9d9cdd7f82c08ee8b9325c0dc5748faf3c82e2047ced5dcaa8b26e58f7903900be2628d0380a222c02d79d8de385df
languageName: node
linkType: hard
@@ -9811,7 +9820,7 @@ __metadata:
languageName: node
linkType: hard
"enhanced-resolve@npm:^5.17.3, enhanced-resolve@npm:^5.18.3":
"enhanced-resolve@npm:^5.18.3":
version: 5.18.4
resolution: "enhanced-resolve@npm:5.18.4"
dependencies:
@@ -9821,6 +9830,16 @@ __metadata:
languageName: node
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":
version: 2.2.0
resolution: "entities@npm:2.2.0"
@@ -9979,13 +9998,20 @@ __metadata:
languageName: node
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
resolution: "es-module-lexer@npm:1.7.0"
checksum: 10c0/4c935affcbfeba7fb4533e1da10fa8568043df1e3574b869385980de9e2d475ddc36769891936dbb07036edb3c3786a8b78ccf44964cd130dedc1f2c984b6c7b
languageName: node
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":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
@@ -12878,7 +12904,7 @@ __metadata:
eslint-config-next: "npm:^16.0.1"
juice: "npm:^11.0.3"
lucide-react: "npm:^0.553.0"
next: "npm:^16.0.10"
next: "npm:^16.1.5"
next-seo: "npm:^6.6.0"
next-sitemap: "npm:^4.2.3"
postcss: "npm:^8.4.33"
@@ -13222,9 +13248,9 @@ __metadata:
linkType: hard
"lodash@npm:^4.17.15":
version: 4.17.21
resolution: "lodash@npm:4.17.21"
checksum: 10c0/d8cbea072bb08655bb4c989da418994b073a608dffa608b09ac04b43a791b12aeae7cd7ad919aa4c925f33b48490b5cfe6c1f71d827956071dae2e7bb3a6b74c
version: 4.17.23
resolution: "lodash@npm:4.17.23"
checksum: 10c0/1264a90469f5bb95d4739c43eb6277d15b6d9e186df4ac68c3620443160fc669e2f14c11e7d8b2ccf078b81d06147c01a8ccced9aab9f9f63d50dcf8cace6bf6
languageName: node
linkType: hard
@@ -13396,8 +13422,8 @@ __metadata:
linkType: hard
"markdown-it@npm:^14.0.0":
version: 14.1.0
resolution: "markdown-it@npm:14.1.0"
version: 14.1.1
resolution: "markdown-it@npm:14.1.1"
dependencies:
argparse: "npm:^2.0.1"
entities: "npm:^4.4.0"
@@ -13407,7 +13433,7 @@ __metadata:
uc.micro: "npm:^2.1.0"
bin:
markdown-it: bin/markdown-it.mjs
checksum: 10c0/9a6bb444181d2db7016a4173ae56a95a62c84d4cbfb6916a399b11d3e6581bf1cc2e4e1d07a2f022ae72c25f56db90fbe1e529fca16fbf9541659dc53480d4b4
checksum: 10c0/c67f2a4c8069a307c78d8c15104bbcb15a2c6b17f4c904364ca218ec2eccf76a397eba1ea05f5ac5de72c4b67fcf115d422d22df0bfb86a09b663f55b9478d4f
languageName: node
linkType: hard
@@ -14641,20 +14667,21 @@ __metadata:
languageName: node
linkType: hard
"next@npm:^16.0.10":
version: 16.0.10
resolution: "next@npm:16.0.10"
"next@npm:^16.1.5":
version: 16.1.6
resolution: "next@npm:16.1.6"
dependencies:
"@next/env": "npm:16.0.10"
"@next/swc-darwin-arm64": "npm:16.0.10"
"@next/swc-darwin-x64": "npm:16.0.10"
"@next/swc-linux-arm64-gnu": "npm:16.0.10"
"@next/swc-linux-arm64-musl": "npm:16.0.10"
"@next/swc-linux-x64-gnu": "npm:16.0.10"
"@next/swc-linux-x64-musl": "npm:16.0.10"
"@next/swc-win32-arm64-msvc": "npm:16.0.10"
"@next/swc-win32-x64-msvc": "npm:16.0.10"
"@next/env": "npm:16.1.6"
"@next/swc-darwin-arm64": "npm:16.1.6"
"@next/swc-darwin-x64": "npm:16.1.6"
"@next/swc-linux-arm64-gnu": "npm:16.1.6"
"@next/swc-linux-arm64-musl": "npm:16.1.6"
"@next/swc-linux-x64-gnu": "npm:16.1.6"
"@next/swc-linux-x64-musl": "npm:16.1.6"
"@next/swc-win32-arm64-msvc": "npm:16.1.6"
"@next/swc-win32-x64-msvc": "npm:16.1.6"
"@swc/helpers": "npm:0.5.15"
baseline-browser-mapping: "npm:^2.8.3"
caniuse-lite: "npm:^1.0.30001579"
postcss: "npm:8.4.31"
sharp: "npm:^0.34.4"
@@ -14696,7 +14723,7 @@ __metadata:
optional: true
bin:
next: dist/bin/next
checksum: 10c0/c0e42bfaa0d38b0ee9a7fc54c748da2dfd807a76d75278b4c4f06768965d7b1035b836cae45e24a074dcf156a13c0fa555caa652886cc0f93e8be2b296dfbb51
checksum: 10c0/543766bf879bb5a5d454dc18cb302953270a92efba1d01dd028ea83c64b69573ce7d6e6c3759ecbaabec0a84131b0237263c24d1ccd7c8a97205e776dcd34e0b
languageName: node
linkType: hard
@@ -15888,11 +15915,11 @@ __metadata:
linkType: hard
"qs@npm:^6.11.0, qs@npm:^6.11.2, qs@npm:^6.14.0, qs@npm:~6.14.0":
version: 6.14.1
resolution: "qs@npm:6.14.1"
version: 6.14.2
resolution: "qs@npm:6.14.2"
dependencies:
side-channel: "npm:^1.1.0"
checksum: 10c0/0e3b22dc451f48ce5940cbbc7c7d9068d895074f8c969c0801ac15c1313d1859c4d738e46dc4da2f498f41a9ffd8c201bd9fb12df67799b827db94cc373d2613
checksum: 10c0/646110124476fc9acf3c80994c8c3a0600cbad06a4ede1c9e93341006e8426d64e85e048baf8f0c4995f0f1bf0f37d1f3acc5ec1455850b81978792969a60ef6
languageName: node
linkType: hard
@@ -17977,19 +18004,19 @@ __metadata:
linkType: hard
"tar@npm:^7.5.2":
version: 7.5.2
resolution: "tar@npm:7.5.2"
version: 7.5.7
resolution: "tar@npm:7.5.7"
dependencies:
"@isaacs/fs-minipass": "npm:^4.0.0"
chownr: "npm:^3.0.0"
minipass: "npm:^7.1.2"
minizlib: "npm:^3.1.0"
yallist: "npm:^5.0.0"
checksum: 10c0/a7d8b801139b52f93a7e34830db0de54c5aa45487c7cb551f6f3d44a112c67f1cb8ffdae856b05fd4f17b1749911f1c26f1e3a23bbe0279e17fd96077f13f467
checksum: 10c0/51f261afc437e1112c3e7919478d6176ea83f7f7727864d8c2cce10f0b03a631d1911644a567348c3063c45abdae39718ba97abb073d22aa3538b9a53ae1e31c
languageName: node
linkType: hard
"terser-webpack-plugin@npm:^5.3.11":
"terser-webpack-plugin@npm:^5.3.16":
version: 5.3.16
resolution: "terser-webpack-plugin@npm:5.3.16"
dependencies:
@@ -18517,9 +18544,9 @@ __metadata:
linkType: hard
"undici@npm:^6.19.5":
version: 6.22.0
resolution: "undici@npm:6.22.0"
checksum: 10c0/47903c489d73e26bd47960cf2f04d63282ed050818b672cb05f8dfb6403381b850cf1b1751832654fd3af22aacd9d780e5e61aff563cd97943f5c4f10d5b3e23
version: 6.23.0
resolution: "undici@npm:6.23.0"
checksum: 10c0/d846b3fdfd05aa6081ba1eab5db6bbc21b283042c7a43722b86d1ee2bf749d7c990ceac0c809f9a07ffd88b1b0f4c0f548a8362c035088cb1997d63abdda499c
languageName: node
linkType: hard
@@ -19007,13 +19034,13 @@ __metadata:
languageName: node
linkType: hard
"watchpack@npm:^2.4.4":
version: 2.4.4
resolution: "watchpack@npm:2.4.4"
"watchpack@npm:^2.5.1":
version: 2.5.1
resolution: "watchpack@npm:2.5.1"
dependencies:
glob-to-regexp: "npm:^0.4.1"
graceful-fs: "npm:^4.1.2"
checksum: 10c0/6c0901f75ce245d33991225af915eea1c5ae4ba087f3aee2b70dd377d4cacb34bef02a48daf109da9d59b2d31ec6463d924a0d72f8618ae1643dd07b95de5275
checksum: 10c0/dffbb483d1f61be90dc570630a1eb308581e2227d507d783b1d94a57ac7b705ecd9a1a4b73d73c15eab596d39874e5276a3d9cb88bbb698bafc3f8d08c34cf17
languageName: node
linkType: hard
@@ -19080,7 +19107,7 @@ __metadata:
framer-motion: "npm:^12.23.24"
juice: "npm:^11.0.3"
lucide-react: "npm:^0.553.0"
next: "npm:^16.0.10"
next: "npm:^16.1.5"
next-seo: "npm:^6.6.0"
next-sitemap: "npm:^4.2.3"
nuqs: "npm:^2.7.3"
@@ -19108,8 +19135,8 @@ __metadata:
linkType: hard
"webpack@npm:^5":
version: 5.103.0
resolution: "webpack@npm:5.103.0"
version: 5.105.0
resolution: "webpack@npm:5.105.0"
dependencies:
"@types/eslint-scope": "npm:^3.7.7"
"@types/estree": "npm:^1.0.8"
@@ -19119,10 +19146,10 @@ __metadata:
"@webassemblyjs/wasm-parser": "npm:^1.14.1"
acorn: "npm:^8.15.0"
acorn-import-phases: "npm:^1.0.3"
browserslist: "npm:^4.26.3"
browserslist: "npm:^4.28.1"
chrome-trace-event: "npm:^1.0.2"
enhanced-resolve: "npm:^5.17.3"
es-module-lexer: "npm:^1.2.1"
enhanced-resolve: "npm:^5.19.0"
es-module-lexer: "npm:^2.0.0"
eslint-scope: "npm:5.1.1"
events: "npm:^3.2.0"
glob-to-regexp: "npm:^0.4.1"
@@ -19133,15 +19160,15 @@ __metadata:
neo-async: "npm:^2.6.2"
schema-utils: "npm:^4.3.3"
tapable: "npm:^2.3.0"
terser-webpack-plugin: "npm:^5.3.11"
watchpack: "npm:^2.4.4"
terser-webpack-plugin: "npm:^5.3.16"
watchpack: "npm:^2.5.1"
webpack-sources: "npm:^3.3.3"
peerDependenciesMeta:
webpack-cli:
optional: true
bin:
webpack: bin/webpack.js
checksum: 10c0/d0cf86f8cac249874d6f36292e25011413ebb5bae82c48fa78a165a217e63db00b1a1f563f5195070eb17a055c6da4b6ab89fbdd37f781abdda862aa8c0bd623
checksum: 10c0/4aea6b976485b5364e122f301c08f48efa84ddb2c0cb5d09f27445d1f2da0b9875cd889e41b58cac3ff05618a9c965be716df52586d151b5f52a7bbed7662174
languageName: node
linkType: hard
@@ -19277,7 +19304,7 @@ __metadata:
fumadocs-openapi: "npm:^10.0.11"
fumadocs-ui: "npm:16.0.8"
lucide-react: "npm:^0.553.0"
next: "npm:^16.0.10"
next: "npm:^16.1.5"
next-sitemap: "npm:^4.2.3"
postcss: "npm:^8.4.33"
react: "npm:^19.2.3"