Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754461d4a8 |
+4
-13
@@ -1,22 +1,13 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
**/node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Build outputs
|
||||
# Patterns without a leading `**/` only match the build-context root, NOT
|
||||
# nested directories. In this monorepo the actual outputs live under
|
||||
# apps/*/{dist,.next,.turbo} and packages/*/{dist,.turbo}; without `**/`
|
||||
# any stale local builds (from running `yarn dev` or `yarn build` on the
|
||||
# host) get copied into the image and override the freshly-built outputs,
|
||||
# producing silently broken builds (MODULE_NOT_FOUND for whatever the stale
|
||||
# bundle still references).
|
||||
**/dist
|
||||
**/.next
|
||||
**/.turbo
|
||||
**/build
|
||||
**/out
|
||||
dist
|
||||
.next
|
||||
.turbo
|
||||
out
|
||||
|
||||
# Development
|
||||
.env
|
||||
|
||||
+14
-17
@@ -52,13 +52,6 @@ SES_CONFIGURATION_SET=plunk-configuration-set
|
||||
# When set, projects can choose to disable email tracking
|
||||
SES_CONFIGURATION_SET_NO_TRACKING=plunk-no-tracking-configuration-set
|
||||
|
||||
# Custom MAIL FROM subdomain — used to construct `<subdomain>.<your-domain>`
|
||||
# when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
# is already used for something else (e.g. an R2/CDN custom domain), since the
|
||||
# MAIL FROM hostname needs MX + TXT records that can't coexist with a CNAME.
|
||||
# Example: MAIL_FROM_SUBDOMAIN=emails → emails.<your-domain>
|
||||
# MAIL_FROM_SUBDOMAIN=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: OAuth Login
|
||||
# ========================================
|
||||
@@ -67,6 +60,15 @@ GITHUB_OAUTH_SECRET=
|
||||
GOOGLE_OAUTH_CLIENT=
|
||||
GOOGLE_OAUTH_SECRET=
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: Stripe Billing
|
||||
# ========================================
|
||||
STRIPE_SK=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
STRIPE_PRICE_ONBOARDING=
|
||||
STRIPE_PRICE_EMAIL_USAGE=
|
||||
STRIPE_METER_EVENT_NAME=emails
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: File Storage (Minio)
|
||||
# ========================================
|
||||
@@ -152,16 +154,11 @@ SMTP_DOMAIN=smtp.example.com
|
||||
# Default: false
|
||||
# DISABLE_SIGNUPS=false
|
||||
|
||||
# ========================================
|
||||
# OPTIONAL: SES Sending Rate
|
||||
# ========================================
|
||||
# Caps the email-worker's send rate (messages per second) for the SES sandbox or
|
||||
# manually-throttled accounts. When unset, the worker probes the AWS account at
|
||||
# startup via ses:GetSendQuota; if that call is denied or transiently fails,
|
||||
# the worker silently falls back to 14/sec which may exceed sandbox limits and
|
||||
# trigger SES throttling errors. Set this explicitly to avoid the silent fallback.
|
||||
# Default: unset (auto-detect, falls back to 14)
|
||||
# EMAIL_RATE_LIMIT_PER_SECOND=1
|
||||
# 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)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.11.0"
|
||||
".": "0.10.0"
|
||||
}
|
||||
|
||||
@@ -1,61 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [0.11.0](https://github.com/useplunk/plunk/compare/v0.10.0...v0.11.0) (2026-05-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Ability to change subscription status in workflows ([fadc19d](https://github.com/useplunk/plunk/commit/fadc19d139084550eb442a8b8368edcd6075cad0))
|
||||
* add 'notTriggeredWithin' operator to segment filters for enhanced event tracking ([0f00ca1](https://github.com/useplunk/plunk/commit/0f00ca1b8c9bd95f2158af845218ef432ab0d498))
|
||||
* add external link to edit email templates in SendEmailStepDialog and WorkflowBuilder ([4ba43dd](https://github.com/useplunk/plunk/commit/4ba43dd3b64776413f813e091f527aa3e56bfc84))
|
||||
* add Markdown cut link to footer and page for improved accessibility ([ccd516e](https://github.com/useplunk/plunk/commit/ccd516e4e882921fb1a8a3e87c47710e51e35311))
|
||||
* add project switching functionality to command palette ([5724ab9](https://github.com/useplunk/plunk/commit/5724ab9536ea4e3bb4e916e963b1f39ba51bb7f3))
|
||||
* add sanitize-html for improved email content sanitization ([735acff](https://github.com/useplunk/plunk/commit/735acff45423cb537f7aa636b9a9d31830775f93))
|
||||
* add search functionality to campaigns list with debounce effect ([d114950](https://github.com/useplunk/plunk/commit/d11495061d907bcee699d56987784fa7ebe31bf6))
|
||||
* add segment membership operators and enhance segment filter functionality ([a3cc622](https://github.com/useplunk/plunk/commit/a3cc62213f40f6a9341113b73b52852292fb9a10))
|
||||
* add SwitchOffer component to promote switching from competitors for enhanced user engagement ([6195b39](https://github.com/useplunk/plunk/commit/6195b39f3dbdd9fe783b79f9ae7f7942509a49a0))
|
||||
* add workflow duplication functionality with API endpoint and UI button ([c6340a1](https://github.com/useplunk/plunk/commit/c6340a1dc7385723d4ec30779340218935d814e6))
|
||||
* enhance campaign scheduling and audience settings UI for better clarity and usability ([7658a59](https://github.com/useplunk/plunk/commit/7658a59b5df4e325ad9eca37356cad3cbb70e942))
|
||||
* implement bulk contact action selector for improved flexibility in bulk operations ([de6335e](https://github.com/useplunk/plunk/commit/de6335e99999242f81e9eda9a20aeccab80a2de4))
|
||||
* implement caching for recent activity count to optimize performance and reduce database load ([f22da4a](https://github.com/useplunk/plunk/commit/f22da4add1e455678f380aba8c2fd02012ef6457))
|
||||
* implement early fraud warning handling in webhooks ([48425d1](https://github.com/useplunk/plunk/commit/48425d1df160a3fd5edb4cbdcc9e6725628db319))
|
||||
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env ([463301b](https://github.com/useplunk/plunk/commit/463301b5db27b08b483db3c56a992ac62d6653b8))
|
||||
* make MAIL FROM subdomain configurable via MAIL_FROM_SUBDOMAIN env var ([e0bf0f6](https://github.com/useplunk/plunk/commit/e0bf0f628af2a155ca3bbc38ef5c2f4ecc0455e6))
|
||||
* refactor template editing layout for improved usability and clarity ([aaf5ac6](https://github.com/useplunk/plunk/commit/aaf5ac65307266a4d9deb02986b610757e929b49))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enhance campaign finalization process to handle pending emails and ensure accurate status updates ([2529c9b](https://github.com/useplunk/plunk/commit/2529c9b4282b003a94013259119e98654ae22b46))
|
||||
* enhance email content parsing and logging for better debugging ([4587e9d](https://github.com/useplunk/plunk/commit/4587e9d670fa3a27fbc1ca7669c8fc75d804e85e))
|
||||
* enhance project name validation to exclude invisible and decorative characters ([29883ff](https://github.com/useplunk/plunk/commit/29883ffc7104d4beb13965bfa65ea609367ede69))
|
||||
* enhance Quick Start card layout for improved responsiveness and usability ([649bbf6](https://github.com/useplunk/plunk/commit/649bbf6d6b9db5139295a455c69425a5565d4323))
|
||||
* handle undefined path in footer component for improved stability ([715961c](https://github.com/useplunk/plunk/commit/715961c007e450731434e5f343acff0debd6a851))
|
||||
* implement mergeContactData method for efficient contact data updates ([a27d564](https://github.com/useplunk/plunk/commit/a27d564e1af6e5cf72c9ef405650f2475bfd0c86))
|
||||
* improve iframe height adjustment logic in EmailEditor component ([9e4aa94](https://github.com/useplunk/plunk/commit/9e4aa9443b53ef38a3af1b269f4a949dd57e758f))
|
||||
* pass DISABLE_SIGNUPS and EMAIL_RATE_LIMIT_PER_SECOND through compose; trim .env.self-host.example ([8a26d60](https://github.com/useplunk/plunk/commit/8a26d605b4aa5fdea0e02a17159c1cae478a7e47))
|
||||
* pass missing env variables from .env.self-host.example to plunk service ([f178c59](https://github.com/useplunk/plunk/commit/f178c59b5bc461f9ba57dc6f0bac9617feaab4a3))
|
||||
* replace font loading method with utility function for improved performance ([ba23a1c](https://github.com/useplunk/plunk/commit/ba23a1c6fa70ca6fa0343391fa8d642155ad45c2))
|
||||
* update activity item colors and backgrounds for improved visual distinction ([e43f70d](https://github.com/useplunk/plunk/commit/e43f70d8a135b048d4e9ed013ffa25f1b2f89687))
|
||||
* update middleware matcher to exclude webmanifest files ([cbb3bff](https://github.com/useplunk/plunk/commit/cbb3bffcf4f789e9c36912c9b0ce0ba9f6a1d4dd))
|
||||
* update not found handling in GET route to return 404 response ([7615b62](https://github.com/useplunk/plunk/commit/7615b62945aa1939bf6a5b3638c232509090de60))
|
||||
* update segment filter logic to retain value and unit, enhance activity name mapping ([d1f357c](https://github.com/useplunk/plunk/commit/d1f357c300c7798d5c8d521e1219e39e7da821dd))
|
||||
* update segment filter logic to retain value and unit, enhance activity name mapping ([a126563](https://github.com/useplunk/plunk/commit/a1265635420e66b7cdef02a7136fdf9c3aec4418))
|
||||
* update TemplateSearchPicker to maintain selected template name on change ([2ea1802](https://github.com/useplunk/plunk/commit/2ea1802290a4378d68e3878a44e502ea8541b301))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* convert forwardRef components to function components for consistency ([361ec0b](https://github.com/useplunk/plunk/commit/361ec0b1eb17647696656c6a6d4cddd5b9348b45))
|
||||
* implement step dialog components for workflow editing ([ed9027b](https://github.com/useplunk/plunk/commit/ed9027b4ef9c37e91c69ed5a90d5b74c08c7a054))
|
||||
* remove unused .png files ([2d05c3f](https://github.com/useplunk/plunk/commit/2d05c3fbc1ded87880a307943bb93106b18cd406))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* expand documentation with new sections on importing contacts, unsubscribe pages, and API key management ([4ddafdc](https://github.com/useplunk/plunk/commit/4ddafdc0419389818de7e45eb4a0c82c2e9382eb))
|
||||
* update README to clarify self-hosted alternative and add inbound emails feature ([80ef65e](https://github.com/useplunk/plunk/commit/80ef65e04b8c362f05ae5b7d5710b398a150cce5))
|
||||
|
||||
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
|
||||
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@
|
||||
"multer": "^2.1.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0",
|
||||
"tldts": "^7.0.30"
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
|
||||
@@ -374,14 +374,13 @@ describe('Domain Verification and Ownership Tests', () => {
|
||||
// EDGE CASES
|
||||
// ========================================
|
||||
describe('Edge Cases', () => {
|
||||
it('should canonicalize mixed-case domain names to lowercase', async () => {
|
||||
it('should handle case-sensitive domain names', async () => {
|
||||
const {project} = await factories.createUserWithProject();
|
||||
|
||||
// DNS is case-insensitive — domains must be stored canonically so a tenant
|
||||
// can't claim "Example.com" while another project owns "example.com".
|
||||
// Domains are typically case-insensitive in DNS, but stored as-is in DB
|
||||
const domain1 = await DomainService.addDomain(project.id, 'Example.com');
|
||||
|
||||
expect(domain1.domain).toBe('example.com');
|
||||
expect(domain1.domain).toBe('Example.com');
|
||||
});
|
||||
|
||||
it('should handle subdomain vs root domain', async () => {
|
||||
|
||||
@@ -45,12 +45,6 @@ export const AWS_SES_REGION = validateEnv('AWS_SES_REGION');
|
||||
export const AWS_SES_ACCESS_KEY_ID = validateEnv('AWS_SES_ACCESS_KEY_ID');
|
||||
export const AWS_SES_SECRET_ACCESS_KEY = validateEnv('AWS_SES_SECRET_ACCESS_KEY');
|
||||
|
||||
// Custom MAIL FROM subdomain used to construct `<subdomain>.<your-domain>`
|
||||
// when a domain is added. Defaults to `plunk`. Override when `plunk.<your-domain>`
|
||||
// is already used for something else (e.g. a CDN), since the MAIL FROM hostname
|
||||
// needs MX + TXT records that can't coexist with a CNAME.
|
||||
export const MAIL_FROM_SUBDOMAIN = validateEnv('MAIL_FROM_SUBDOMAIN', '').trim() || 'plunk';
|
||||
|
||||
// Email Processing Rate Limit (optional override)
|
||||
// If not set, will automatically fetch from AWS SES account quota
|
||||
// Set this to override AWS quota (useful for setting lower limits or testing)
|
||||
|
||||
@@ -321,12 +321,9 @@ export class Auth {
|
||||
data: {password: hashedPassword},
|
||||
});
|
||||
|
||||
// Delete token and invalidate cache (id + email projections both cache the password hash)
|
||||
// Delete token and invalidate cache
|
||||
await redis.del(Keys.User.passwordResetToken(token));
|
||||
await redis.del(Keys.User.id(userId));
|
||||
if (user.email) {
|
||||
await redis.del(Keys.User.email(user.email));
|
||||
}
|
||||
|
||||
return res.json({success: true, data: {message: 'Password reset successfully'}});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
GITHUB_OAUTH_ENABLED,
|
||||
GOOGLE_OAUTH_ENABLED,
|
||||
LANDING_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
NODE_ENV,
|
||||
S3_ENABLED,
|
||||
SMTP_DOMAIN,
|
||||
@@ -63,7 +62,6 @@ export class Config {
|
||||
},
|
||||
aws: {
|
||||
sesRegion: AWS_SES_REGION,
|
||||
mailFromSubdomain: MAIL_FROM_SUBDOMAIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {isAuthenticated, requireEmailVerified} from '../middleware/auth.js';
|
||||
import {BillingLimitService} from '../services/BillingLimitService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {UserService} from '../services/UserService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
@@ -118,8 +117,6 @@ export class Users {
|
||||
data,
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
return res.status(200).json(project);
|
||||
}
|
||||
|
||||
@@ -133,12 +130,6 @@ export class Users {
|
||||
// Verify user has admin/owner access to this project
|
||||
await MembershipService.requireAdminAccess(auth.userId!, id);
|
||||
|
||||
// Capture the existing keys so we can drop them from cache after rotation
|
||||
const previousProject = await prisma.project.findUnique({
|
||||
where: {id},
|
||||
select: {public: true, secret: true},
|
||||
});
|
||||
|
||||
// Generate new unique API keys
|
||||
const publicKey = `pk_${randomBytes(32).toString('hex')}`;
|
||||
const secretKey = `sk_${randomBytes(32).toString('hex')}`;
|
||||
@@ -163,13 +154,6 @@ export class Users {
|
||||
},
|
||||
});
|
||||
|
||||
// Invalidate cached lookups for both old and new keys so revoked keys
|
||||
// stop authorizing requests immediately instead of after cache TTL.
|
||||
await ProjectService.invalidate(id, [
|
||||
{public: previousProject?.public, secret: previousProject?.secret},
|
||||
{public: project.public, secret: project.secret},
|
||||
]);
|
||||
|
||||
// Send notification about API key regeneration
|
||||
await NtfyService.notifyApiKeysRegenerated(project.name!, id!, auth.userId!);
|
||||
|
||||
@@ -246,14 +230,6 @@ export class Users {
|
||||
client_reference_id: project.id, // Store project ID for webhook
|
||||
line_items: lineItems,
|
||||
...(checkoutCurrency && {currency: checkoutCurrency}),
|
||||
custom_fields: [
|
||||
{
|
||||
key: 'promo_code',
|
||||
label: {type: 'custom', custom: 'Promo code'},
|
||||
type: 'text',
|
||||
optional: true,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
billing_cycle_anchor: billingCycleAnchor,
|
||||
},
|
||||
|
||||
@@ -19,7 +19,6 @@ import {EventService} from '../services/EventService.js';
|
||||
import {MembershipService} from '../services/MembershipService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {NtfyService} from '../services/NtfyService.js';
|
||||
import {ProjectService} from '../services/ProjectService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {CatchAsync} from '../utils/asyncHandler.js';
|
||||
|
||||
@@ -531,28 +530,10 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [
|
||||
{public: updatedProject.public, secret: updatedProject.secret},
|
||||
]);
|
||||
|
||||
// Base onboarding credit: refund the 1-unit card-verification charge
|
||||
let creditBalance = -100;
|
||||
|
||||
// Switching-offer promo: 2 extra units of credit if the customer typed SWITCH
|
||||
// into the Promo code custom field on Stripe Checkout.
|
||||
const promoField = session.custom_fields?.find((f) => f.key === 'promo_code');
|
||||
const promoCode = promoField?.text?.value?.trim().toUpperCase();
|
||||
if (promoCode === 'SWITCH') {
|
||||
creditBalance -= 200;
|
||||
signale.success(`[WEBHOOK] SWITCH promo applied for project ${projectId}`);
|
||||
} else if (promoCode) {
|
||||
signale.info(`[WEBHOOK] Unknown promo code "${promoCode}" entered for project ${projectId}`);
|
||||
}
|
||||
|
||||
// Update Stripe customer name to match project name and add credit for onboarding fee
|
||||
await stripe.customers.update(customerId, {
|
||||
name: updatedProject.name,
|
||||
balance: creditBalance,
|
||||
balance: -100,
|
||||
});
|
||||
|
||||
signale.success(`[WEBHOOK] Checkout completed for project ${projectId}`);
|
||||
@@ -614,8 +595,6 @@ export class Webhooks {
|
||||
data: {disabled: true},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
await NtfyService.notifyProjectDisabledForPayment(project.name, project.id);
|
||||
|
||||
// Send email notification to project members
|
||||
@@ -661,8 +640,6 @@ export class Webhooks {
|
||||
},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(project.id, [{public: project.public, secret: project.secret}]);
|
||||
|
||||
signale.warn(`[WEBHOOK] Subscription deleted for project ${project.name} (${project.id})`);
|
||||
|
||||
// Send notification about subscription cancellation
|
||||
|
||||
@@ -135,47 +135,6 @@ export class ContactService {
|
||||
* Update a contact
|
||||
* Uses unique constraint violation to check for duplicates (more efficient)
|
||||
*/
|
||||
/**
|
||||
* Merge an incoming partial data object into existing contact data.
|
||||
* - `null` value on a key deletes that key
|
||||
* - empty strings are ignored
|
||||
* - reserved/system-generated keys are silently filtered
|
||||
* - `{value, persistent: false}` entries are skipped (non-persistent)
|
||||
*/
|
||||
private static mergeContactData(
|
||||
existing: Prisma.JsonValue | null,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged: Record<string, unknown> =
|
||||
existing && typeof existing === 'object' && !Array.isArray(existing) ? {...(existing as Record<string, unknown>)} : {};
|
||||
|
||||
const reservedFields = ['plunk_id', 'plunk_email', 'id', 'email', 'unsubscribeUrl', 'subscribeUrl', 'manageUrl'];
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
if (reservedFields.includes(key)) continue;
|
||||
if (value === '') continue;
|
||||
if (value === null) {
|
||||
delete merged[key];
|
||||
continue;
|
||||
}
|
||||
if (key === 'locale' && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
(value as {persistent: unknown}).persistent === false
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
public static async update(
|
||||
projectId: string,
|
||||
contactId: string,
|
||||
@@ -190,14 +149,7 @@ export class ContactService {
|
||||
updateData.email = data.email;
|
||||
}
|
||||
if (data.data !== undefined) {
|
||||
if (data.data === null) {
|
||||
updateData.data = Prisma.JsonNull;
|
||||
} else if (typeof data.data === 'object' && !Array.isArray(data.data)) {
|
||||
const merged = ContactService.mergeContactData(existing.data, data.data as Record<string, unknown>);
|
||||
updateData.data = Object.keys(merged).length > 0 ? toPrismaJson(merged) : Prisma.JsonNull;
|
||||
} else {
|
||||
throw new HttpException(400, 'data must be an object');
|
||||
}
|
||||
updateData.data = data.data === null ? Prisma.JsonNull : data.data;
|
||||
}
|
||||
if (data.subscribed !== undefined) {
|
||||
updateData.subscribed = data.subscribed;
|
||||
@@ -273,7 +225,69 @@ export class ContactService {
|
||||
},
|
||||
});
|
||||
|
||||
const mergedData = ContactService.mergeContactData(existing?.data ?? null, data ?? {});
|
||||
// Process data to merge with existing data
|
||||
let mergedData: Record<string, unknown> = {};
|
||||
|
||||
if (existing?.data && typeof existing.data === 'object' && !Array.isArray(existing.data)) {
|
||||
// Start with existing data
|
||||
mergedData = {...existing.data};
|
||||
}
|
||||
|
||||
// Merge new data (if provided)
|
||||
if (data) {
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
// Skip reserved system-generated fields
|
||||
// These fields are dynamically added during template rendering and cannot be overridden
|
||||
const reservedFields = [
|
||||
'plunk_id',
|
||||
'plunk_email',
|
||||
'id',
|
||||
'email',
|
||||
'unsubscribeUrl',
|
||||
'subscribeUrl',
|
||||
'manageUrl',
|
||||
];
|
||||
if (reservedFields.includes(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip empty string values - they don't provide meaningful data
|
||||
// and can cause issues with template rendering and data integrity
|
||||
if (value === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Delete field if null is passed (allows removing fields from contact data)
|
||||
if (value === null) {
|
||||
delete mergedData[key];
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate locale field (special user-settable field)
|
||||
// Only validate type - any locale string is accepted since we default to English if unsupported
|
||||
if (key === 'locale') {
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
|
||||
// Handle non-persistent data format: { value: "...", persistent: false }
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'value' in value &&
|
||||
'persistent' in value &&
|
||||
value.persistent === false
|
||||
) {
|
||||
// Non-persistent fields are not stored in contact data
|
||||
// They would be used only for the current operation (like email template rendering)
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the value
|
||||
mergedData[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
// Track subscription status change
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import signale from 'signale';
|
||||
import {getDomain as getRegistrableDomain} from 'tldts';
|
||||
import {DomainUnverifiedEmail, DomainVerifiedEmail, sendPlatformEmail} from '@plunk/email';
|
||||
import {DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
@@ -17,14 +16,6 @@ import {
|
||||
} from './SESService.js';
|
||||
|
||||
export class DomainService {
|
||||
/**
|
||||
* Canonicalize a domain name for storage and comparison.
|
||||
* DNS is case-insensitive and a trailing dot represents the same name.
|
||||
*/
|
||||
public static canonicalize(domain: string): string {
|
||||
return domain.trim().toLowerCase().replace(/\.$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a domain by ID
|
||||
*/
|
||||
@@ -50,16 +41,14 @@ export class DomainService {
|
||||
* Add a new domain to a project and start verification
|
||||
*/
|
||||
public static async addDomain(projectId: string, domain: string) {
|
||||
const canonical = this.canonicalize(domain);
|
||||
|
||||
// Start verification process with AWS SES
|
||||
const dkimTokens = await verifyDomain(canonical);
|
||||
const dkimTokens = await verifyDomain(domain);
|
||||
|
||||
// Create domain record
|
||||
const newDomain = await prisma.domain.create({
|
||||
data: {
|
||||
projectId,
|
||||
domain: canonical,
|
||||
domain,
|
||||
verified: false,
|
||||
dkimTokens,
|
||||
},
|
||||
@@ -71,7 +60,7 @@ export class DomainService {
|
||||
});
|
||||
|
||||
// Send notification about domain added
|
||||
await NtfyService.notifyDomainAdded(canonical, newDomain.project.name, projectId);
|
||||
await NtfyService.notifyDomainAdded(domain, newDomain.project.name, projectId);
|
||||
|
||||
return newDomain;
|
||||
}
|
||||
@@ -364,7 +353,7 @@ export class DomainService {
|
||||
throw new HttpException(400, 'Invalid email format');
|
||||
}
|
||||
|
||||
const domainName = this.canonicalize(emailParts[1] ?? '');
|
||||
const domainName = emailParts[1];
|
||||
|
||||
// Find domain in database
|
||||
const domain = await prisma.domain.findFirst({
|
||||
@@ -400,11 +389,12 @@ export class DomainService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the registrable root domain from a domain name using the Public Suffix List.
|
||||
* e.g. "mail.example.com" → "example.com", "mail.example.co.uk" → "example.co.uk"
|
||||
* Extract the registrable root domain (last two labels) from a domain name.
|
||||
* e.g. "mail.example.com" → "example.com", "example.com" → "example.com"
|
||||
*/
|
||||
private static rootDomain(domain: string): string {
|
||||
return getRegistrableDomain(domain) ?? domain;
|
||||
const parts = domain.split('.');
|
||||
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -414,11 +404,10 @@ export class DomainService {
|
||||
public static async checkSubdomainOfDisabledRoot(
|
||||
domain: string,
|
||||
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
|
||||
const canonical = this.canonicalize(domain);
|
||||
const root = this.rootDomain(canonical);
|
||||
const root = this.rootDomain(domain);
|
||||
|
||||
// Only relevant when the submitted domain is actually a subdomain
|
||||
if (root === canonical) {
|
||||
if (root === domain) {
|
||||
return {blocked: false};
|
||||
}
|
||||
|
||||
@@ -450,9 +439,8 @@ export class DomainService {
|
||||
* @returns Object with exists flag and membership info
|
||||
*/
|
||||
public static async checkDomainOwnership(domain: string, userId: string) {
|
||||
const canonical = this.canonicalize(domain);
|
||||
const existingDomain = await prisma.domain.findFirst({
|
||||
where: {domain: canonical},
|
||||
where: {domain},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import signale from 'signale';
|
||||
|
||||
import {Keys} from './keys.js';
|
||||
import {redis, wrapRedis} from '../database/redis.js';
|
||||
import {wrapRedis} from '../database/redis.js';
|
||||
import {prisma} from '../database/prisma.js';
|
||||
|
||||
export class ProjectService {
|
||||
@@ -30,28 +28,4 @@ export class ProjectService {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cached project lookups (id + secret/public keys).
|
||||
* Must be called whenever a project's API keys, `disabled` flag, or other
|
||||
* auth-affecting fields change, otherwise stale records can keep
|
||||
* revoked keys or just-disabled projects authorized until cache TTL.
|
||||
*
|
||||
* Accepts the previous key values too, so rotated keys are also dropped.
|
||||
*/
|
||||
public static async invalidate(
|
||||
projectId: string,
|
||||
keys?: {secret?: string | null; public?: string | null}[],
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cacheKeys = new Set<string>([Keys.Project.id(projectId)]);
|
||||
for (const k of keys ?? []) {
|
||||
if (k.secret) cacheKeys.add(Keys.Project.secret(k.secret));
|
||||
if (k.public) cacheKeys.add(Keys.Project.public(k.public));
|
||||
}
|
||||
await Promise.all([...cacheKeys].map(key => redis.del(key)));
|
||||
} catch (error) {
|
||||
signale.warn(`[PROJECT] Failed to invalidate cache for ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
AWS_SES_REGION,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
DASHBOARD_URI,
|
||||
MAIL_FROM_SUBDOMAIN,
|
||||
SES_CONFIGURATION_SET,
|
||||
SES_CONFIGURATION_SET_NO_TRACKING,
|
||||
TRACKING_TOGGLE_ENABLED,
|
||||
@@ -251,13 +250,10 @@ export const verifyDomain = async (domain: string): Promise<string[]> => {
|
||||
// Verify DKIM for the domain
|
||||
const DKIM = await ses.verifyDomainDkim({Domain: domain});
|
||||
|
||||
// Set custom MAIL FROM domain. The subdomain defaults to `plunk` and can be
|
||||
// overridden via the MAIL_FROM_SUBDOMAIN env var — useful when `plunk.<domain>`
|
||||
// is already in use for something else (e.g., a CNAME to a CDN), since the
|
||||
// MAIL FROM subdomain needs MX + TXT records that conflict with a CNAME.
|
||||
// Set custom MAIL FROM domain (plunk.yourdomain.com)
|
||||
await ses.setIdentityMailFromDomain({
|
||||
Identity: domain,
|
||||
MailFromDomain: `${MAIL_FROM_SUBDOMAIN}.${domain}`,
|
||||
MailFromDomain: `plunk.${domain}`,
|
||||
});
|
||||
|
||||
return DKIM.DkimTokens ?? [];
|
||||
|
||||
@@ -9,7 +9,6 @@ import {redis} from '../database/redis.js';
|
||||
import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {ProjectService} from './ProjectService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {
|
||||
AUTO_PROJECT_DISABLE,
|
||||
@@ -712,14 +711,11 @@ export class SecurityService {
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
const disabled = await prisma.project.update({
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
select: {public: true, secret: true},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
|
||||
|
||||
// Log critical security event
|
||||
signale.error(
|
||||
`[SECURITY] Project ${projectId} (${project.name}) has been automatically disabled due to security violations:`,
|
||||
@@ -973,14 +969,11 @@ ${strippedBody.substring(0, 2000)}`,
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
const disabled = await prisma.project.update({
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
select: {public: true, secret: true},
|
||||
});
|
||||
|
||||
await ProjectService.invalidate(projectId, [{public: disabled.public, secret: disabled.secret}]);
|
||||
|
||||
const violation = `A policy violation was detected. Please contact support for more details.`;
|
||||
|
||||
// Log critical security event
|
||||
|
||||
@@ -953,10 +953,7 @@ export class WorkflowExecutionService {
|
||||
body: method !== 'GET' ? JSON.stringify(payload) : undefined,
|
||||
});
|
||||
|
||||
const {body: responseData, truncated} = await WorkflowExecutionService.readBodyCapped(
|
||||
response,
|
||||
WorkflowExecutionService.WEBHOOK_RESPONSE_MAX_BYTES,
|
||||
);
|
||||
const responseData = await response.text();
|
||||
let parsedResponse;
|
||||
try {
|
||||
parsedResponse = JSON.parse(responseData);
|
||||
@@ -970,65 +967,9 @@ export class WorkflowExecutionService {
|
||||
statusCode: response.status,
|
||||
success: response.ok,
|
||||
response: parsedResponse,
|
||||
...(truncated ? {truncated: true} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private static readonly WEBHOOK_RESPONSE_MAX_BYTES = 64 * 1024;
|
||||
|
||||
/**
|
||||
* Read a fetch Response body up to a maximum number of bytes.
|
||||
* Aborts further reading once the cap is reached so a malicious server
|
||||
* cannot exhaust worker memory.
|
||||
*/
|
||||
private static async readBodyCapped(
|
||||
response: Response,
|
||||
maxBytes: number,
|
||||
): Promise<{body: string; truncated: boolean}> {
|
||||
if (!response.body) {
|
||||
return {body: '', truncated: false};
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
let truncated = false;
|
||||
|
||||
try {
|
||||
while (received < maxBytes) {
|
||||
const {done, value} = await reader.read();
|
||||
if (done) break;
|
||||
if (!value) continue;
|
||||
|
||||
const remaining = maxBytes - received;
|
||||
if (value.byteLength > remaining) {
|
||||
chunks.push(value.subarray(0, remaining));
|
||||
received += remaining;
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
chunks.push(value);
|
||||
received += value.byteLength;
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const merged = new Uint8Array(received);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
merged.set(chunk, offset);
|
||||
offset += chunk.byteLength;
|
||||
}
|
||||
|
||||
return {body: new TextDecoder().decode(merged), truncated};
|
||||
}
|
||||
|
||||
/**
|
||||
* UPDATE_CONTACT step - Update contact data
|
||||
*/
|
||||
|
||||
@@ -4,7 +4,7 @@ export const Keys = {
|
||||
return `account:id:${id}`;
|
||||
},
|
||||
email(email: string): string {
|
||||
return `account:${email.trim().toLowerCase()}`;
|
||||
return `account:${email}`;
|
||||
},
|
||||
emailVerificationToken(token: string): string {
|
||||
return `auth:email_verification:${token}`;
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
"@tiptap/starter-kit": "^3.11.0",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.2.6",
|
||||
"next-seo": "^6.6.0",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
# Plunk
|
||||
|
||||
> Plunk is an open-source, all-in-one email platform for developers. It unifies marketing, transactional, and broadcast email behind a single API — built to handle millions of contacts with workflows, segments, templates, and deliverability tooling out of the box.
|
||||
|
||||
Plunk can be used as a hosted service at useplunk.com or self-hosted via Docker. Source code lives at https://github.com/useplunk/plunk.
|
||||
|
||||
Pages listed below with a `.md` suffix serve a Markdown version (also available by requesting the same URL with `Accept: text/markdown`). Other pages are HTML only.
|
||||
|
||||
For full product documentation, see the docs site: https://docs.useplunk.com/llms.txt
|
||||
|
||||
## Product
|
||||
|
||||
- [Plunk home](https://www.useplunk.com/index.md): Product overview, features, and positioning
|
||||
- [Pricing](https://www.useplunk.com/pricing.md): Plans, included volume, and overage pricing
|
||||
- [Made by humans](https://www.useplunk.com/made-by-humans): The team and story behind Plunk
|
||||
|
||||
## Features
|
||||
|
||||
- [Email editor](https://www.useplunk.com/features/email-editor.md): Drag-and-drop and code-based template editor
|
||||
- [Workflows](https://www.useplunk.com/features/workflows.md): Event- and segment-triggered automation flows
|
||||
- [Segments](https://www.useplunk.com/features/segments.md): Dynamic and static contact segments
|
||||
- [SMTP](https://www.useplunk.com/features/smtp.md): Send through Plunk over standard SMTP
|
||||
- [Inbound email](https://www.useplunk.com/features/inbound-email.md): Receive email at your verified domain and turn it into events
|
||||
|
||||
## Comparisons
|
||||
|
||||
- [Plunk vs Resend](https://www.useplunk.com/vs/resend)
|
||||
- [Plunk vs SendGrid](https://www.useplunk.com/vs/sendgrid)
|
||||
- [Plunk vs Mailchimp](https://www.useplunk.com/vs/mailchimp)
|
||||
- [Plunk vs Mailgun](https://www.useplunk.com/vs/mailgun)
|
||||
- [Plunk vs Postmark](https://www.useplunk.com/vs/postmark)
|
||||
- [Plunk vs Customer.io](https://www.useplunk.com/vs/customerio)
|
||||
- [Plunk vs Loops](https://www.useplunk.com/vs/loops)
|
||||
- [Plunk vs Brevo](https://www.useplunk.com/vs/brevo)
|
||||
- [Plunk vs ActiveCampaign](https://www.useplunk.com/vs/activecampaign)
|
||||
- [Plunk vs Klaviyo](https://www.useplunk.com/vs/klaviyo)
|
||||
- [Plunk vs ConvertKit](https://www.useplunk.com/vs/convertkit)
|
||||
- [Plunk vs Bento](https://www.useplunk.com/vs/bento)
|
||||
- [Plunk vs MailerLite](https://www.useplunk.com/vs/mailerlite)
|
||||
- [Plunk vs Amazon SES](https://www.useplunk.com/vs/amazon-ses)
|
||||
- [Plunk vs Mailjet](https://www.useplunk.com/vs/mailjet)
|
||||
- [Plunk vs Buttondown](https://www.useplunk.com/vs/buttondown)
|
||||
- [All comparisons](https://www.useplunk.com/vs)
|
||||
|
||||
## Guides
|
||||
|
||||
- [Email API guide](https://www.useplunk.com/guides/email-api-guide): Choosing and integrating with an email API
|
||||
- [Email deliverability](https://www.useplunk.com/guides/email-deliverability): Reaching the inbox reliably
|
||||
- [Email bounce rate](https://www.useplunk.com/guides/email-bounce-rate): What it is and how to reduce it
|
||||
- [Email open rate](https://www.useplunk.com/guides/email-open-rate): Benchmarks and improvement tactics
|
||||
- [Email click-through rate](https://www.useplunk.com/guides/email-click-through-rate): Measuring and improving CTR
|
||||
- [Email sender reputation](https://www.useplunk.com/guides/email-sender-reputation): Protecting your sending reputation
|
||||
- [Email marketing best practices](https://www.useplunk.com/guides/email-marketing-best-practices)
|
||||
- [Transactional vs marketing email](https://www.useplunk.com/guides/transactional-vs-marketing-email)
|
||||
- [What is SPF](https://www.useplunk.com/guides/what-is-spf)
|
||||
- [What is DKIM](https://www.useplunk.com/guides/what-is-dkim)
|
||||
- [What is DMARC](https://www.useplunk.com/guides/what-is-dmarc)
|
||||
- [All guides](https://www.useplunk.com/guides)
|
||||
|
||||
## Tools
|
||||
|
||||
- [Verify email](https://www.useplunk.com/tools/verify-email): Check a single email address for validity
|
||||
- [Spam checker](https://www.useplunk.com/tools/spam-checker): Score an email for spam triggers
|
||||
- [Markdown to email](https://www.useplunk.com/tools/markdown-to-email): Convert Markdown into HTML email
|
||||
- [SPF checker](https://www.useplunk.com/tools/spf-checker)
|
||||
- [DKIM checker](https://www.useplunk.com/tools/dkim-checker)
|
||||
- [DMARC checker](https://www.useplunk.com/tools/dmarc-checker)
|
||||
- [MX checker](https://www.useplunk.com/tools/mx-checker)
|
||||
- [Email headers analyzer](https://www.useplunk.com/tools/email-headers)
|
||||
- [All tools](https://www.useplunk.com/tools)
|
||||
|
||||
## Optional
|
||||
|
||||
- [Documentation](https://docs.useplunk.com): Product, API, and self-hosting docs
|
||||
- [Discord community](https://www.useplunk.com/discord)
|
||||
- [Privacy policy](https://www.useplunk.com/privacy)
|
||||
- [Terms of service](https://www.useplunk.com/terms)
|
||||
- [Data processing agreement](https://www.useplunk.com/dpa)
|
||||
@@ -1,6 +1,5 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import {useRouter} from 'next/router';
|
||||
import {WIKI_URI} from '../../lib/constants';
|
||||
import logo from '../../../public/assets/logo.svg';
|
||||
|
||||
@@ -8,10 +7,6 @@ import logo from '../../../public/assets/logo.svg';
|
||||
*
|
||||
*/
|
||||
export default function Footer() {
|
||||
const router = useRouter();
|
||||
const path = (router.asPath || '/').split(/[?#]/)[0] ?? '/';
|
||||
const trimmed = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const mdHref = `${trimmed}.md`;
|
||||
return (
|
||||
<>
|
||||
<footer className={'border-t border-neutral-200 bg-white'}>
|
||||
@@ -268,15 +263,8 @@ export default function Footer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex flex-col gap-2 border-t border-neutral-200 pt-8 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="mt-16 border-t border-neutral-200 pt-8">
|
||||
<p className="text-sm text-neutral-500">© {new Date().getFullYear()} Plunk. All rights reserved.</p>
|
||||
<p style={{fontFamily: 'var(--font-mono)'}} className="text-[11px] text-neutral-400">
|
||||
Reading this with electronic eyes? Append{' '}
|
||||
<a href={mdHref} className="text-neutral-500 underline decoration-dotted underline-offset-2 transition hover:text-neutral-900">
|
||||
<code>.md</code>
|
||||
</a>{' '}
|
||||
to any URL for the Markdown cut.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import React, {useEffect, useState} from 'react';
|
||||
import {ArrowRight, Gift, X} from 'lucide-react';
|
||||
import {DASHBOARD_URI} from '../lib/constants';
|
||||
|
||||
interface SwitchOfferProps {
|
||||
competitorName: string;
|
||||
}
|
||||
|
||||
export function SwitchOffer({competitorName}: SwitchOfferProps) {
|
||||
const storageKey = `switch-offer-dismissed:${competitorName}`;
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (window.localStorage.getItem(storageKey) === '1') return;
|
||||
const t = window.setTimeout(() => setVisible(true), 800);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [storageKey]);
|
||||
|
||||
const dismiss = () => {
|
||||
setVisible(false);
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(storageKey, '1');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 24, scale: 0.96}}
|
||||
animate={{opacity: 1, y: 0, scale: 1}}
|
||||
exit={{opacity: 0, y: 16, scale: 0.97}}
|
||||
transition={{duration: 0.45, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'pointer-events-auto fixed bottom-4 right-4 z-50 w-[calc(100vw-2rem)] max-w-sm sm:bottom-6 sm:right-6'
|
||||
}
|
||||
role="dialog"
|
||||
aria-label={`Switch from ${competitorName} to Plunk offer`}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'relative overflow-hidden rounded-2xl border border-neutral-900 bg-white shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]'
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={dismiss}
|
||||
aria-label="Dismiss offer"
|
||||
className={
|
||||
'absolute right-2.5 top-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full text-neutral-500 transition hover:bg-neutral-100 hover:text-neutral-900'
|
||||
}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
<div className={'p-5 pr-10 sm:p-6 sm:pr-12'}>
|
||||
<div
|
||||
className={
|
||||
'inline-flex items-center gap-1.5 rounded-full border border-neutral-200 bg-neutral-50 px-2 py-1'
|
||||
}
|
||||
>
|
||||
<Gift className="h-3 w-3 text-neutral-700" />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-600'}
|
||||
>
|
||||
Switching offer
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-3 text-lg font-bold leading-[1.15] tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
Switching from {competitorName}?
|
||||
<br />
|
||||
<span className={'text-neutral-500'}>Get 2,000 free emails.</span>
|
||||
</h3>
|
||||
|
||||
<p className={'mt-3 text-xs leading-relaxed text-neutral-600'}>
|
||||
Sign up and enter this code at checkout to redeem 2,000 email credits.
|
||||
</p>
|
||||
|
||||
<div className={'mt-3 rounded-xl border border-dashed border-neutral-300 bg-neutral-50 px-3 py-2.5'}>
|
||||
<div className={'mt-1 flex items-baseline justify-between gap-2'}>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-base font-bold tracking-[0.08em] text-neutral-900'}
|
||||
>
|
||||
SWITCH
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'group mt-4 inline-flex w-full items-center justify-center gap-1.5 rounded-full bg-neutral-900 px-4 py-2.5 text-xs font-semibold text-white transition hover:bg-neutral-800'
|
||||
}
|
||||
>
|
||||
Claim your credits
|
||||
<ArrowRight className="h-3.5 w-3.5 transition-transform group-hover:translate-x-0.5" />
|
||||
</motion.a>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -4,4 +4,3 @@ export * from './ComparisonTable';
|
||||
export * from './FAQSection';
|
||||
export * from './CodeBlock';
|
||||
export * from './SectionHeader';
|
||||
export * from './SwitchOffer';
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
export const MARKDOWN_SLUGS: ReadonlySet<string> = new Set([
|
||||
'index',
|
||||
'pricing',
|
||||
'features/workflows',
|
||||
'features/segments',
|
||||
'features/inbound-email',
|
||||
'features/email-editor',
|
||||
'features/smtp',
|
||||
]);
|
||||
|
||||
export function hasMarkdownVariant(pathname: string): boolean {
|
||||
const slug = pathname.replace(/^\/+|\/+$/g, '') || 'index';
|
||||
return MARKDOWN_SLUGS.has(slug);
|
||||
}
|
||||
@@ -341,5 +341,3 @@ Plunk provides SMTP credentials so you can send emails from any application or f
|
||||
[Back to features](/features) | [Pricing](/pricing) | [Documentation](https://docs.useplunk.com)
|
||||
`,
|
||||
};
|
||||
|
||||
export {MARKDOWN_SLUGS, hasMarkdownVariant} from './markdown-slugs';
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
import { hasMarkdownVariant } from './content/markdown-slugs';
|
||||
|
||||
type Negotiated = 'markdown' | 'html' | 'none';
|
||||
|
||||
function parseAccept(accept: string): Array<{ type: string; q: number }> {
|
||||
@@ -61,9 +59,6 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('Vary', 'Accept');
|
||||
if (hasMarkdownVariant(pathname)) {
|
||||
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,9 @@ import '../styles/globals.css';
|
||||
import React, {useEffect} from 'react';
|
||||
import Head from 'next/head';
|
||||
import {AppProps} from 'next/app';
|
||||
import {useRouter} from 'next/router';
|
||||
import {toast, Toaster} from 'sonner';
|
||||
import {SWRConfig} from 'swr';
|
||||
import {network} from '../lib/network';
|
||||
import {hasMarkdownVariant} from '../content/markdown-slugs';
|
||||
import {DefaultSeo} from 'next-seo';
|
||||
import Script from 'next/script';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
@@ -39,10 +37,6 @@ const mono = JetBrains_Mono({
|
||||
* @param props.pageProps
|
||||
*/
|
||||
function App({Component, pageProps}: AppProps) {
|
||||
const router = useRouter();
|
||||
const pathname = (router.asPath.split('?')[0] ?? '').split('#')[0] ?? '/';
|
||||
const markdownHref = hasMarkdownVariant(pathname) ? `${pathname === '/' ? '/index' : pathname}.md` : null;
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const message = searchParams.get('message');
|
||||
@@ -57,7 +51,6 @@ function App({Component, pageProps}: AppProps) {
|
||||
<Head>
|
||||
<title>Plunk | The Open-Source Email Platform</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" key={'viewport'} />
|
||||
{markdownHref && <link rel="alternate" type="text/markdown" href={markdownHref} />}
|
||||
</Head>
|
||||
<Toaster position={'top-right'} />
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,8 +170,6 @@ export default function ActiveCampaignComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-activecampaign" />
|
||||
|
||||
<SwitchOffer competitorName="ActiveCampaign" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -186,8 +186,6 @@ export default function AmazonSesComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-amazon-ses" />
|
||||
|
||||
<SwitchOffer competitorName="Amazon SES" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -169,8 +169,6 @@ export default function BentoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-bento" />
|
||||
|
||||
<SwitchOffer competitorName="Bento" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,8 +193,6 @@ export default function BrevoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-brevo" />
|
||||
|
||||
<SwitchOffer competitorName="Brevo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -186,8 +186,6 @@ export default function ButtondownComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-buttondown" />
|
||||
|
||||
<SwitchOffer competitorName="Buttondown" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,8 +193,6 @@ export default function ConvertkitComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-convertkit" />
|
||||
|
||||
<SwitchOffer competitorName="ConvertKit" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,8 +189,6 @@ export default function CustomerioComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-customerio" />
|
||||
|
||||
<SwitchOffer competitorName="Customer.io" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,8 +170,6 @@ export default function KlaviyoComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-klaviyo" />
|
||||
|
||||
<SwitchOffer competitorName="Klaviyo" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,8 +193,6 @@ export default function LoopsComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-loops" />
|
||||
|
||||
<SwitchOffer competitorName="Loops" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -195,8 +195,6 @@ export default function MailchimpComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailchimp" />
|
||||
|
||||
<SwitchOffer competitorName="Mailchimp" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -170,8 +170,6 @@ export default function MailerliteComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailerlite" />
|
||||
|
||||
<SwitchOffer competitorName="MailerLite" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,8 +189,6 @@ export default function MailgunComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailgun" />
|
||||
|
||||
<SwitchOffer competitorName="Mailgun" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -186,8 +186,6 @@ export default function MailjetComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-mailjet" />
|
||||
|
||||
<SwitchOffer competitorName="Mailjet" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -190,8 +190,6 @@ export default function PostmarkComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-postmark" />
|
||||
|
||||
<SwitchOffer competitorName="Postmark" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -189,8 +189,6 @@ export default function ResendComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-resend" />
|
||||
|
||||
<SwitchOffer competitorName="Resend" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar, SwitchOffer} from '../../components';
|
||||
import {ComparisonTable, FAQSection, Footer, Navbar} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI, WIKI_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
@@ -193,8 +193,6 @@ export default function SendGridComparison() {
|
||||
{/* FAQ */}
|
||||
<FAQSection faqs={faqs} schemaId="faq-schema-sendgrid" />
|
||||
|
||||
<SwitchOffer competitorName="SendGrid" />
|
||||
|
||||
{/* CTA */}
|
||||
<section className={'relative overflow-hidden border-t border-neutral-900 bg-neutral-900 text-white'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-24 sm:px-10 sm:py-32'}>
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"framer-motion": "^12.23.24",
|
||||
"juice": "^11.0.3",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.2.6",
|
||||
"next-seo": "^6.6.0",
|
||||
"nuqs": "^2.7.3",
|
||||
"react": "19.2.3",
|
||||
|
||||
@@ -335,8 +335,6 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<div className="space-y-4">
|
||||
{domains.map(domain => {
|
||||
const status = getDomainStatus(domain);
|
||||
const mailFromSubdomain = config?.aws?.mailFromSubdomain ?? 'plunk';
|
||||
const mailFromHost = `${mailFromSubdomain}.${domain.domain}`;
|
||||
return (
|
||||
<div key={domain.id} className="border border-neutral-200 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -496,8 +494,8 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-600 mb-2">
|
||||
Set up a custom MAIL FROM domain ({mailFromHost}) to improve deliverability and handle
|
||||
bounces/complaints.
|
||||
Set up a custom MAIL FROM domain (plunk.{domain.domain}) to improve deliverability and
|
||||
handle bounces/complaints.
|
||||
</p>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
@@ -524,16 +522,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<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">
|
||||
{mailFromHost}
|
||||
plunk.{domain.domain}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(mailFromHost, 3000)}
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3000)}
|
||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||
>
|
||||
<AnimatedCopyIcon
|
||||
isCopied={copiedToken === `${mailFromHost}-3000`}
|
||||
isCopied={copiedToken === `plunk.${domain.domain}-3000`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
@@ -573,16 +571,16 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<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">
|
||||
{mailFromHost}
|
||||
plunk.{domain.domain}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleCopyToken(mailFromHost, 3001)}
|
||||
onClick={() => handleCopyToken(`plunk.${domain.domain}`, 3001)}
|
||||
className="shrink-0 h-6 w-6 p-0 overflow-hidden"
|
||||
>
|
||||
<AnimatedCopyIcon
|
||||
isCopied={copiedToken === `${mailFromHost}-3001`}
|
||||
isCopied={copiedToken === `plunk.${domain.domain}-3001`}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -306,31 +306,21 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
iframeDoc.write(fullHtml);
|
||||
iframeDoc.close();
|
||||
|
||||
// Auto-adjust iframe height to content. Reset to a small value first so
|
||||
// body content that uses % / vh heights doesn't lock the iframe to its
|
||||
// previous size (which would otherwise cause the iframe to grow by the
|
||||
// padding offset on every preview-device switch).
|
||||
// Auto-adjust iframe height to content
|
||||
const adjustHeight = () => {
|
||||
if (!iframe.contentWindow) return;
|
||||
iframe.style.height = '0px';
|
||||
const doc = iframe.contentWindow.document;
|
||||
const height = Math.max(
|
||||
doc.body?.scrollHeight ?? 0,
|
||||
doc.documentElement?.scrollHeight ?? 0,
|
||||
);
|
||||
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
||||
if (iframe.contentWindow) {
|
||||
const height = iframe.contentWindow.document.body.scrollHeight;
|
||||
iframe.style.height = `${Math.max(400, height + 40)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
const timeouts = [
|
||||
window.setTimeout(adjustHeight, 100),
|
||||
window.setTimeout(adjustHeight, 300),
|
||||
];
|
||||
iframe.contentWindow?.addEventListener('load', adjustHeight);
|
||||
|
||||
return () => {
|
||||
timeouts.forEach(window.clearTimeout);
|
||||
iframe.contentWindow?.removeEventListener('load', adjustHeight);
|
||||
};
|
||||
// Adjust height after content loads
|
||||
if (iframe.contentWindow) {
|
||||
iframe.contentWindow.addEventListener('load', adjustHeight);
|
||||
// Also adjust immediately for already-loaded content
|
||||
setTimeout(adjustHeight, 100);
|
||||
setTimeout(adjustHeight, 300); // Fallback for slow-loading images
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -21,7 +21,6 @@ export interface ConfigResponse {
|
||||
};
|
||||
aws: {
|
||||
sesRegion: string;
|
||||
mailFromSubdomain: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,13 +26,6 @@ export default async function Page(props: {params: Promise<{slug?: string[]}>})
|
||||
markdownUrl={`/llms.mdx${page.url}`}
|
||||
githubUrl={`https://github.com/useplunk/plunk/blob/next/apps/wiki/content/docs/${page.path}`}
|
||||
/>
|
||||
<p className="ml-auto hidden text-[11px] text-fd-muted-foreground sm:block">
|
||||
Reading this with electronic eyes? Add{' '}
|
||||
<a href={`${page.url}.md`} className="underline decoration-dotted underline-offset-2 transition hover:text-fd-foreground">
|
||||
<code>.md</code>
|
||||
</a>{' '}
|
||||
for the Markdown cut.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DocsBody>
|
||||
@@ -64,11 +57,6 @@ export async function generateMetadata(props: {params: Promise<{slug?: string[]}
|
||||
return {
|
||||
title: page.data.title,
|
||||
description: page.data.description,
|
||||
alternates: {
|
||||
types: {
|
||||
'text/markdown': `${page.url}.md`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
images: [{url: ogUrl.toString(), width: 1200, height: 630}],
|
||||
},
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import {openapi} from '@/lib/openapi';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export async function GET() {
|
||||
const schemas = await openapi.getSchemas();
|
||||
const first = Object.values(schemas)[0];
|
||||
if (!first) return new Response('Not found', {status: 404});
|
||||
|
||||
return new Response(JSON.stringify(first.bundled), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=300, s-maxage=3600',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,17 +1,9 @@
|
||||
import {renderOpenAPIOperationFromMDX} from '@/lib/render-openapi-operation';
|
||||
import {source} from '@/lib/source';
|
||||
import type {InferPageType} from 'fumadocs-core/source';
|
||||
|
||||
export async function getLLMText(page: InferPageType<typeof source>) {
|
||||
const isOpenAPI = Boolean((page.data as {_openapi?: unknown})._openapi);
|
||||
|
||||
if (isOpenAPI) {
|
||||
const raw = await page.data.getText('raw');
|
||||
const rendered = await renderOpenAPIOperationFromMDX(raw, page.data.title, page.url);
|
||||
if (rendered) return rendered;
|
||||
}
|
||||
|
||||
const processed = await page.data.getText('processed');
|
||||
|
||||
return `# ${page.data.title} (${page.url})
|
||||
|
||||
${processed}`;
|
||||
|
||||
@@ -1,279 +0,0 @@
|
||||
import {openapi} from '@/lib/openapi';
|
||||
|
||||
interface SchemaLike {
|
||||
type?: string | string[];
|
||||
format?: string;
|
||||
enum?: unknown[];
|
||||
description?: string;
|
||||
default?: unknown;
|
||||
example?: unknown;
|
||||
required?: string[];
|
||||
properties?: Record<string, SchemaLike>;
|
||||
items?: SchemaLike;
|
||||
oneOf?: SchemaLike[];
|
||||
anyOf?: SchemaLike[];
|
||||
allOf?: SchemaLike[];
|
||||
nullable?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Parameter {
|
||||
name: string;
|
||||
in: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
schema?: SchemaLike;
|
||||
}
|
||||
|
||||
interface Operation {
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
deprecated?: boolean;
|
||||
tags?: string[];
|
||||
parameters?: Parameter[];
|
||||
requestBody?: {
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
content?: Record<string, {schema?: SchemaLike}>;
|
||||
};
|
||||
responses?: Record<
|
||||
string,
|
||||
{
|
||||
description?: string;
|
||||
content?: Record<string, {schema?: SchemaLike}>;
|
||||
}
|
||||
>;
|
||||
security?: Array<Record<string, string[]>>;
|
||||
}
|
||||
|
||||
function typeLabel(schema?: SchemaLike): string {
|
||||
if (!schema) return 'unknown';
|
||||
if (schema.enum) return `enum (${schema.enum.map(v => JSON.stringify(v)).join(' | ')})`;
|
||||
if (schema.oneOf) return schema.oneOf.map(typeLabel).join(' | ');
|
||||
if (schema.anyOf) return schema.anyOf.map(typeLabel).join(' | ');
|
||||
if (schema.allOf) return schema.allOf.map(typeLabel).join(' & ');
|
||||
if (Array.isArray(schema.type)) return schema.type.join(' | ');
|
||||
const base = schema.type ?? 'object';
|
||||
if (base === 'array' && schema.items) return `array<${typeLabel(schema.items)}>`;
|
||||
if (schema.format) return `${base} (${schema.format})`;
|
||||
return base;
|
||||
}
|
||||
|
||||
function renderSchemaTree(schema: SchemaLike | undefined, depth = 0, lines: string[] = []): string[] {
|
||||
if (!schema) return lines;
|
||||
const indent = ' '.repeat(depth);
|
||||
|
||||
if (schema.allOf) {
|
||||
for (const sub of schema.allOf) renderSchemaTree(sub, depth, lines);
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (schema.type === 'object' || schema.properties) {
|
||||
const required = new Set(schema.required ?? []);
|
||||
const props = schema.properties ?? {};
|
||||
for (const [name, prop] of Object.entries(props)) {
|
||||
const tag = required.has(name) ? ' (required)' : '';
|
||||
const desc = prop.description ? ` — ${prop.description.replace(/\n+/g, ' ')}` : '';
|
||||
lines.push(`${indent}- \`${name}\`: ${typeLabel(prop)}${tag}${desc}`);
|
||||
if (prop.type === 'object' || prop.properties) {
|
||||
renderSchemaTree(prop, depth + 1, lines);
|
||||
} else if (prop.type === 'array' && prop.items && (prop.items.type === 'object' || prop.items.properties)) {
|
||||
lines.push(`${indent} items:`);
|
||||
renderSchemaTree(prop.items, depth + 2, lines);
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
lines.push(`${indent}- items: ${typeLabel(schema.items)}`);
|
||||
renderSchemaTree(schema.items, depth + 1, lines);
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
function exampleFromSchema(schema?: SchemaLike): unknown {
|
||||
if (!schema) return undefined;
|
||||
if (schema.example !== undefined) return schema.example;
|
||||
if (schema.default !== undefined) return schema.default;
|
||||
if (schema.enum && schema.enum.length > 0) return schema.enum[0];
|
||||
if (schema.allOf) {
|
||||
const merged: Record<string, unknown> = {};
|
||||
for (const sub of schema.allOf) Object.assign(merged, exampleFromSchema(sub) as object ?? {});
|
||||
return merged;
|
||||
}
|
||||
if (schema.oneOf?.[0]) return exampleFromSchema(schema.oneOf[0]);
|
||||
if (schema.anyOf?.[0]) return exampleFromSchema(schema.anyOf[0]);
|
||||
|
||||
if (schema.properties) {
|
||||
const out: Record<string, unknown> = {};
|
||||
const required = new Set(schema.required ?? Object.keys(schema.properties));
|
||||
for (const [name, prop] of Object.entries(schema.properties)) {
|
||||
if (!required.has(name) && schema.required) continue;
|
||||
out[name] = exampleFromSchema(prop);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
if (schema.type === 'array') {
|
||||
const item = exampleFromSchema(schema.items);
|
||||
return item === undefined ? [] : [item];
|
||||
}
|
||||
|
||||
switch (schema.type) {
|
||||
case 'string':
|
||||
if (schema.format === 'email') return 'user@example.com';
|
||||
if (schema.format === 'date-time') return new Date().toISOString();
|
||||
if (schema.format === 'uri' || schema.format === 'url') return 'https://example.com';
|
||||
return 'string';
|
||||
case 'integer':
|
||||
case 'number':
|
||||
return 0;
|
||||
case 'boolean':
|
||||
return false;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findOperationByMethodPath(
|
||||
doc: {paths?: Record<string, Record<string, Operation>>},
|
||||
method: string,
|
||||
path: string,
|
||||
): Operation | undefined {
|
||||
const pathItem = doc.paths?.[path];
|
||||
return pathItem?.[method.toLowerCase()];
|
||||
}
|
||||
|
||||
function extractOperationRef(mdxBody: string): {method: string; path: string} | undefined {
|
||||
const block = mdxBody.match(/operations=\{(\[[\s\S]*?\])\}/);
|
||||
if (!block) return undefined;
|
||||
const pathMatch = block[1].match(/['"]?path['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
const methodMatch = block[1].match(/['"]?method['"]?\s*:\s*['"]([^'"]+)['"]/);
|
||||
if (!pathMatch || !methodMatch) return undefined;
|
||||
return {path: pathMatch[1], method: methodMatch[1]};
|
||||
}
|
||||
|
||||
export async function renderOpenAPIOperationFromMDX(
|
||||
mdxBody: string,
|
||||
fallbackTitle: string,
|
||||
pageUrl: string,
|
||||
): Promise<string | undefined> {
|
||||
const ref = extractOperationRef(mdxBody);
|
||||
if (!ref) return undefined;
|
||||
|
||||
const schemas = await openapi.getSchemas();
|
||||
const first = Object.values(schemas)[0];
|
||||
if (!first) return undefined;
|
||||
|
||||
const doc = first.dereferenced as {
|
||||
servers?: Array<{url: string}>;
|
||||
paths?: Record<string, Record<string, Operation>>;
|
||||
};
|
||||
const op = findOperationByMethodPath(doc, ref.method, ref.path);
|
||||
if (!op) return undefined;
|
||||
|
||||
const lines: string[] = [];
|
||||
const method = ref.method.toUpperCase();
|
||||
const baseUrl = doc.servers?.[0]?.url ?? '';
|
||||
|
||||
lines.push(`# ${op.summary ?? fallbackTitle} (${pageUrl})`);
|
||||
lines.push('');
|
||||
lines.push(`\`${method} ${ref.path}\``);
|
||||
lines.push('');
|
||||
if (baseUrl) {
|
||||
lines.push(`Base URL: \`${baseUrl}\``);
|
||||
lines.push('');
|
||||
}
|
||||
if (op.description) {
|
||||
lines.push(op.description);
|
||||
lines.push('');
|
||||
}
|
||||
if (op.deprecated) {
|
||||
lines.push('> **Deprecated.** This endpoint should not be used in new integrations.');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
if (op.parameters && op.parameters.length > 0) {
|
||||
const grouped: Record<string, Parameter[]> = {};
|
||||
for (const p of op.parameters) (grouped[p.in] ??= []).push(p);
|
||||
for (const [location, params] of Object.entries(grouped)) {
|
||||
lines.push(`## ${location[0].toUpperCase()}${location.slice(1)} parameters`);
|
||||
lines.push('');
|
||||
for (const p of params) {
|
||||
const req = p.required ? ' (required)' : '';
|
||||
const desc = p.description ? ` — ${p.description.replace(/\n+/g, ' ')}` : '';
|
||||
lines.push(`- \`${p.name}\`: ${typeLabel(p.schema)}${req}${desc}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
const body = op.requestBody?.content?.['application/json']?.schema;
|
||||
if (body) {
|
||||
lines.push('## Request body');
|
||||
lines.push('');
|
||||
if (op.requestBody?.description) {
|
||||
lines.push(op.requestBody.description);
|
||||
lines.push('');
|
||||
}
|
||||
const tree = renderSchemaTree(body);
|
||||
if (tree.length > 0) {
|
||||
lines.push(...tree);
|
||||
lines.push('');
|
||||
}
|
||||
const example = exampleFromSchema(body);
|
||||
if (example !== undefined) {
|
||||
lines.push('Example:');
|
||||
lines.push('');
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(example, null, 2));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
if (op.responses) {
|
||||
lines.push('## Responses');
|
||||
lines.push('');
|
||||
for (const [status, resp] of Object.entries(op.responses)) {
|
||||
const desc = resp.description ? ` — ${resp.description.replace(/\n+/g, ' ')}` : '';
|
||||
lines.push(`### \`${status}\`${desc}`);
|
||||
lines.push('');
|
||||
const schema = resp.content?.['application/json']?.schema;
|
||||
if (schema) {
|
||||
const tree = renderSchemaTree(schema);
|
||||
if (tree.length > 0) {
|
||||
lines.push(...tree);
|
||||
lines.push('');
|
||||
}
|
||||
const example = exampleFromSchema(schema);
|
||||
if (example !== undefined) {
|
||||
lines.push('```json');
|
||||
lines.push(JSON.stringify(example, null, 2));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (baseUrl) {
|
||||
lines.push('## Example request');
|
||||
lines.push('');
|
||||
lines.push('```bash');
|
||||
const curlParts = [`curl -X ${method} '${baseUrl}${ref.path}'`, " -H 'Authorization: Bearer YOUR_API_KEY'"];
|
||||
if (body) {
|
||||
curlParts.push(" -H 'Content-Type: application/json'");
|
||||
const example = exampleFromSchema(body);
|
||||
curlParts.push(` -d '${JSON.stringify(example)}'`);
|
||||
}
|
||||
lines.push(curlParts.join(' \\\n'));
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
@@ -57,10 +57,9 @@ export function middleware(request: NextRequest) {
|
||||
|
||||
const response = NextResponse.next();
|
||||
response.headers.set('Vary', 'Accept');
|
||||
response.headers.append('Link', `<${pathname}.md>; rel="alternate"; type="text/markdown"`);
|
||||
return response;
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!llms\\.mdx|api/search|openapi\\.json|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|json|xml|txt|webmanifest)).*)',],
|
||||
matcher: ['/((?!llms\\.mdx|api/search|_next|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"fumadocs-openapi": "^10.0.11",
|
||||
"fumadocs-ui": "16.0.8",
|
||||
"lucide-react": "^0.553.0",
|
||||
"next": "^16.2.3",
|
||||
"next": "^16.2.6",
|
||||
"next-sitemap": "^4.2.3",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# Plunk Documentation
|
||||
|
||||
> Documentation for Plunk, an open-source email platform for developers. Covers core concepts, integration guides, the REST API, and self-hosting.
|
||||
|
||||
Every documentation page is available as Markdown by appending `.md` to the path (for example, `https://docs.useplunk.com/concepts/contacts.md`), or by requesting the page URL with `Accept: text/markdown`. API reference endpoints render the OpenAPI operation — including parameters, request/response schemas, and a sample curl — directly into the Markdown response.
|
||||
|
||||
The full OpenAPI specification is available at [https://docs.useplunk.com/openapi.json](https://docs.useplunk.com/openapi.json).
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [Welcome to Plunk](https://docs.useplunk.com/index.md): What Plunk is and how to get started
|
||||
|
||||
## Concepts
|
||||
|
||||
- [Contacts](https://docs.useplunk.com/concepts/contacts.md): Manage and organize your contacts
|
||||
- [Segments](https://docs.useplunk.com/concepts/segments.md): Group and target contacts with dynamic or static segments
|
||||
- [Templates](https://docs.useplunk.com/concepts/templates.md): Reusable email templates for campaigns, workflows, and transactional emails
|
||||
- [Campaigns](https://docs.useplunk.com/concepts/campaigns.md): One-off broadcast emails sent to a defined audience
|
||||
- [Workflows](https://docs.useplunk.com/concepts/workflows.md): Automated, multi-step journeys triggered by events, segments, schedules, or manual entry
|
||||
- [Transactional emails](https://docs.useplunk.com/concepts/transactional-emails.md): Send emails via API
|
||||
- [Billing](https://docs.useplunk.com/concepts/billing.md): How Plunk's pricing, limits, and consumption work
|
||||
|
||||
## Guides
|
||||
|
||||
- [API keys](https://docs.useplunk.com/guides/api-keys.md): Plunk's two-key model and how to rotate or revoke keys safely
|
||||
- [Verifying domains](https://docs.useplunk.com/guides/verifying-domains.md): Verify sending domains so emails reach the inbox
|
||||
- [Tracking](https://docs.useplunk.com/guides/tracking.md): Track opens and clicks on emails sent through Plunk
|
||||
- [Webhooks](https://docs.useplunk.com/guides/webhooks.md): Send real-time event data from Plunk to your own application
|
||||
- [Receiving emails](https://docs.useplunk.com/guides/receiving-emails.md): Receive inbound email at your verified domain and turn it into events
|
||||
- [Custom fields](https://docs.useplunk.com/guides/custom-fields.md): Store arbitrary data on contacts and use it for personalization and segmentation
|
||||
- [Segment filter reference](https://docs.useplunk.com/guides/segment-filters.md): How to write filters for dynamic segments, campaign audiences, and workflow conditions
|
||||
- [Importing contacts from CSV](https://docs.useplunk.com/guides/importing-contacts.md): Bulk-load contacts and their custom fields
|
||||
- [Unsubscribe & preferences pages](https://docs.useplunk.com/guides/unsubscribe-pages.md): Hosted pages for unsubscribe, resubscribe, and preference management
|
||||
- [Localization](https://docs.useplunk.com/guides/localization.md): Translate the unsubscribe footer and contact-facing pages
|
||||
- [List hygiene](https://docs.useplunk.com/guides/list-hygiene.md): Maintain a healthy email list
|
||||
|
||||
## API Reference
|
||||
|
||||
- [API overview](https://docs.useplunk.com/api-reference/overview.md): Complete Plunk API documentation
|
||||
- [Error codes](https://docs.useplunk.com/api-reference/errors.md): API error codes and troubleshooting
|
||||
|
||||
### Public API
|
||||
|
||||
- [Send transactional email](https://docs.useplunk.com/api-reference/public-api/sendEmail.md): POST — send a transactional email; auto-creates/updates contacts
|
||||
- [Track event](https://docs.useplunk.com/api-reference/public-api/trackEvent.md): POST — track an event for a contact
|
||||
- [Verify email address](https://docs.useplunk.com/api-reference/public-api/verifyEmail.md): POST — validate an address, check disposable/MX/typos
|
||||
|
||||
### Contacts
|
||||
|
||||
- [Create or update contact](https://docs.useplunk.com/api-reference/contacts/createContact.md): POST — upsert by email
|
||||
- [Get contact](https://docs.useplunk.com/api-reference/contacts/getContact.md): GET — single contact by ID
|
||||
- [List contacts](https://docs.useplunk.com/api-reference/contacts/listContacts.md): GET — paginated, cursor-based
|
||||
- [Update contact](https://docs.useplunk.com/api-reference/contacts/updateContact.md): PATCH
|
||||
- [Delete contact](https://docs.useplunk.com/api-reference/contacts/deleteContact.md): DELETE
|
||||
|
||||
### Campaigns
|
||||
|
||||
- [Create campaign](https://docs.useplunk.com/api-reference/campaigns/createCampaign.md): POST
|
||||
- [List campaigns](https://docs.useplunk.com/api-reference/campaigns/listCampaigns.md): GET — paginated
|
||||
- [Send or schedule campaign](https://docs.useplunk.com/api-reference/campaigns/sendCampaign.md): POST — send immediately or schedule
|
||||
|
||||
### Templates
|
||||
|
||||
- [Create template](https://docs.useplunk.com/api-reference/templates/createTemplate.md): POST
|
||||
- [List templates](https://docs.useplunk.com/api-reference/templates/listTemplates.md): GET — paginated
|
||||
|
||||
### Segments
|
||||
|
||||
- [Create segment](https://docs.useplunk.com/api-reference/segments/createSegment.md): POST
|
||||
- [List segments](https://docs.useplunk.com/api-reference/segments/listSegments.md): GET
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
- [Self-hosting introduction](https://docs.useplunk.com/self-hosting/introduction.md): Deploy Plunk on your own infrastructure
|
||||
- [Docker deployment](https://docs.useplunk.com/self-hosting/docker.md): Deploy with Docker Compose
|
||||
- [Environment variables](https://docs.useplunk.com/self-hosting/environment-variables.md): Configuration reference
|
||||
- [AWS SES setup](https://docs.useplunk.com/self-hosting/email-setup.md): Configure email delivery
|
||||
|
||||
## Optional
|
||||
|
||||
- [Marketing site](https://www.useplunk.com)
|
||||
- [GitHub repository](https://github.com/useplunk/plunk)
|
||||
- [Discord community](https://www.useplunk.com/discord)
|
||||
@@ -154,10 +154,6 @@ services:
|
||||
SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET}
|
||||
SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-}
|
||||
|
||||
# Custom MAIL FROM subdomain (defaults to 'plunk'; override when
|
||||
# plunk.<your-domain> is already used for something else, e.g. a CDN)
|
||||
MAIL_FROM_SUBDOMAIN: ${MAIL_FROM_SUBDOMAIN:-}
|
||||
|
||||
# Optional: OAuth
|
||||
GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-}
|
||||
GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-}
|
||||
@@ -194,14 +190,6 @@ services:
|
||||
# Security
|
||||
AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false}
|
||||
|
||||
# Self-hosting user management (documented in .env.self-host.example
|
||||
# and read by apps/api/src/app/constants.ts at import time)
|
||||
DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false}
|
||||
|
||||
# Explicit SES sending rate (avoid silent fallback to 14/sec when
|
||||
# ses:GetSendQuota is denied or transiently fails at worker startup)
|
||||
EMAIL_RATE_LIMIT_PER_SECOND: ${EMAIL_RATE_LIMIT_PER_SECOND:-}
|
||||
|
||||
volumes:
|
||||
# Persistent storage for application data
|
||||
- plunk_data:/app/data
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "plunk",
|
||||
"version": "0.11.0",
|
||||
"version": "0.10.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "turbo build",
|
||||
|
||||
@@ -3003,10 +3003,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/env@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/env@npm:16.2.3"
|
||||
checksum: 10c0/56c3fee8ea226efe59ef065e054380f872c00c45c9fe4475eaa45f80773c3c1adc3ead3ccdd77447d3c1aeb4b3004aaaa033dd4a100d3e572fd01b83f992dde8
|
||||
"@next/env@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/env@npm:16.2.6"
|
||||
checksum: 10c0/466722ce30a9561d29c08a7ba78091a47fef3644f422d04751c7124a24457ffe11eb25bb6c59c1e1d57735d9c68c58d185cc19ea58befd7a9c5b81dc05ca550d
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3033,9 +3033,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-arm64@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.2.3"
|
||||
"@next/swc-darwin-arm64@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-darwin-arm64@npm:16.2.6"
|
||||
conditions: os=darwin & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3047,9 +3047,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-darwin-x64@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-darwin-x64@npm:16.2.3"
|
||||
"@next/swc-darwin-x64@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-darwin-x64@npm:16.2.6"
|
||||
conditions: os=darwin & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3061,9 +3061,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-gnu@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.2.3"
|
||||
"@next/swc-linux-arm64-gnu@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-linux-arm64-gnu@npm:16.2.6"
|
||||
conditions: os=linux & cpu=arm64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3075,9 +3075,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-arm64-musl@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.2.3"
|
||||
"@next/swc-linux-arm64-musl@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-linux-arm64-musl@npm:16.2.6"
|
||||
conditions: os=linux & cpu=arm64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3089,9 +3089,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-gnu@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.2.3"
|
||||
"@next/swc-linux-x64-gnu@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-linux-x64-gnu@npm:16.2.6"
|
||||
conditions: os=linux & cpu=x64 & libc=glibc
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3103,9 +3103,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-linux-x64-musl@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.2.3"
|
||||
"@next/swc-linux-x64-musl@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-linux-x64-musl@npm:16.2.6"
|
||||
conditions: os=linux & cpu=x64 & libc=musl
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3117,9 +3117,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-arm64-msvc@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.2.3"
|
||||
"@next/swc-win32-arm64-msvc@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-win32-arm64-msvc@npm:16.2.6"
|
||||
conditions: os=win32 & cpu=arm64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -3131,9 +3131,9 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@next/swc-win32-x64-msvc@npm:16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.2.3"
|
||||
"@next/swc-win32-x64-msvc@npm:16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "@next/swc-win32-x64-msvc@npm:16.2.6"
|
||||
conditions: os=win32 & cpu=x64
|
||||
languageName: node
|
||||
linkType: hard
|
||||
@@ -8104,7 +8104,6 @@ __metadata:
|
||||
sanitize-html: "npm:^2.17.3"
|
||||
signale: "npm:^1.4.0"
|
||||
stripe: "npm:^20.0.0"
|
||||
tldts: "npm:^7.0.30"
|
||||
tsx: "npm:^4.20.6"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
@@ -13264,7 +13263,7 @@ __metadata:
|
||||
eslint-config-next: "npm:^16.0.1"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.2.3"
|
||||
next: "npm:^16.2.6"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
@@ -15043,19 +15042,19 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"next@npm:^16.2.3":
|
||||
version: 16.2.3
|
||||
resolution: "next@npm:16.2.3"
|
||||
"next@npm:^16.2.6":
|
||||
version: 16.2.6
|
||||
resolution: "next@npm:16.2.6"
|
||||
dependencies:
|
||||
"@next/env": "npm:16.2.3"
|
||||
"@next/swc-darwin-arm64": "npm:16.2.3"
|
||||
"@next/swc-darwin-x64": "npm:16.2.3"
|
||||
"@next/swc-linux-arm64-gnu": "npm:16.2.3"
|
||||
"@next/swc-linux-arm64-musl": "npm:16.2.3"
|
||||
"@next/swc-linux-x64-gnu": "npm:16.2.3"
|
||||
"@next/swc-linux-x64-musl": "npm:16.2.3"
|
||||
"@next/swc-win32-arm64-msvc": "npm:16.2.3"
|
||||
"@next/swc-win32-x64-msvc": "npm:16.2.3"
|
||||
"@next/env": "npm:16.2.6"
|
||||
"@next/swc-darwin-arm64": "npm:16.2.6"
|
||||
"@next/swc-darwin-x64": "npm:16.2.6"
|
||||
"@next/swc-linux-arm64-gnu": "npm:16.2.6"
|
||||
"@next/swc-linux-arm64-musl": "npm:16.2.6"
|
||||
"@next/swc-linux-x64-gnu": "npm:16.2.6"
|
||||
"@next/swc-linux-x64-musl": "npm:16.2.6"
|
||||
"@next/swc-win32-arm64-msvc": "npm:16.2.6"
|
||||
"@next/swc-win32-x64-msvc": "npm:16.2.6"
|
||||
"@swc/helpers": "npm:0.5.15"
|
||||
baseline-browser-mapping: "npm:^2.9.19"
|
||||
caniuse-lite: "npm:^1.0.30001579"
|
||||
@@ -15099,7 +15098,7 @@ __metadata:
|
||||
optional: true
|
||||
bin:
|
||||
next: dist/bin/next
|
||||
checksum: 10c0/8a9d27fc773d69f7f471cf1a23bde2ab2950e0411ef3e0d5c1664ed9654e94c3304eae1c4283ec0fa4e70e7b3f4416913350e118e0c18e8b055693dc5d021883
|
||||
checksum: 10c0/3572071eb0e8051c3b007224dcf642037ce27a2f4b75c45f7e0fe7f2343e98e66604ce8696dde56ecd72963900d330d3a3af0f068f34cfc67520180263effa5f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -18564,24 +18563,6 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tldts-core@npm:^7.0.30":
|
||||
version: 7.0.30
|
||||
resolution: "tldts-core@npm:7.0.30"
|
||||
checksum: 10c0/e3cd730e96b0e9c0332fcaab44d0257b668f9089644508e4f6f870d37bbf5c218243b7e83aa39690c87b386d1b0ad577772a5994969c4c81cc25a476f783ccd7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"tldts@npm:^7.0.30":
|
||||
version: 7.0.30
|
||||
resolution: "tldts@npm:7.0.30"
|
||||
dependencies:
|
||||
tldts-core: "npm:^7.0.30"
|
||||
bin:
|
||||
tldts: bin/cli.js
|
||||
checksum: 10c0/c36f7b480f09128303158e4738a82426c33e8da9f77d4fb57a2d5ef5896c803d7a3c1d53ade965712f9cb4946935139b6d192a18698665556ca504493c7c265e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"to-regex-range@npm:^5.0.1":
|
||||
version: 5.0.1
|
||||
resolution: "to-regex-range@npm:5.0.1"
|
||||
@@ -19558,7 +19539,7 @@ __metadata:
|
||||
framer-motion: "npm:^12.23.24"
|
||||
juice: "npm:^11.0.3"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.2.3"
|
||||
next: "npm:^16.2.6"
|
||||
next-seo: "npm:^6.6.0"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
nuqs: "npm:^2.7.3"
|
||||
@@ -19755,7 +19736,7 @@ __metadata:
|
||||
fumadocs-openapi: "npm:^10.0.11"
|
||||
fumadocs-ui: "npm:16.0.8"
|
||||
lucide-react: "npm:^0.553.0"
|
||||
next: "npm:^16.2.3"
|
||||
next: "npm:^16.2.6"
|
||||
next-sitemap: "npm:^4.2.3"
|
||||
postcss: "npm:^8.4.33"
|
||||
react: "npm:^19.2.3"
|
||||
|
||||
Reference in New Issue
Block a user