Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ff94cb5a7 | ||
|
|
735acff454 | ||
|
|
1193d6c7e6 | ||
|
|
62661ae45f | ||
|
|
4587e9d670 | ||
|
|
88a8a02e53 | ||
|
|
cbb3bffcf4 | ||
|
|
80ef65e04b | ||
|
|
361ec0b1eb | ||
|
|
ed9027b4ef | ||
|
|
d1f357c300 | ||
|
|
a126563542 | ||
|
|
a3cc62213f | ||
|
|
2d05c3fbc1 | ||
|
|
c0752e1363 | ||
|
|
1b09ec1e13 | ||
|
|
0f00ca1b8c | ||
|
|
7615b62945 | ||
|
|
ba23a1c6fa | ||
|
|
4b5815c1d6 | ||
|
|
29883ffc71 | ||
|
|
5724ab9536 | ||
|
|
0c15cc84df | ||
|
|
a729789922 | ||
|
|
26ec5864ae | ||
|
|
48425d1df1 | ||
|
|
a5e8d239ee | ||
|
|
532293e52a | ||
|
|
fd066a6991 | ||
|
|
bb72911fa2 | ||
|
|
9fafeb7d4b | ||
|
|
11ea9d5e12 | ||
|
|
b73927067f | ||
|
|
4b65e718ad | ||
|
|
f6f291966f | ||
|
|
11c428d4ff | ||
|
|
2eb407993b | ||
|
|
10946511b0 | ||
|
|
5f741c66c7 | ||
|
|
d7eb85ffdd | ||
|
|
1c89ed083e | ||
|
|
f40cc86cec | ||
|
|
6f31fa2503 | ||
|
|
98f1e80fca | ||
|
|
c1bcd358cc | ||
|
|
517753c420 | ||
|
|
ab3edd7dc9 | ||
|
|
c910d20bb7 | ||
|
|
bdc9b30ef2 | ||
|
|
c8190b953f | ||
|
|
1637d54e1b | ||
|
|
15294a40d8 | ||
|
|
bb78c86c40 | ||
|
|
c0e0ad8bc6 | ||
|
|
208c809a90 | ||
|
|
b7eb2549d4 | ||
|
|
91d0d2d297 | ||
|
|
862875428d | ||
|
|
2da8b065d0 | ||
|
|
60146fcc10 | ||
|
|
c278a0fd94 | ||
|
|
0b3aa28101 | ||
|
|
ecaf99665a | ||
|
|
3d9b4e4eb3 | ||
|
|
e048fc1570 | ||
|
|
4322de929a | ||
|
|
bfdfde68fb | ||
|
|
3649392b47 | ||
|
|
cbde3cce3f | ||
|
|
df18979e5e | ||
|
|
a981a8bba4 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.9.0"
|
||||
".": "0.10.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [0.10.0](https://github.com/useplunk/plunk/compare/v0.9.0...v0.10.0) (2026-05-01)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add className prop to EmptyState component for custom styling ([c278a0f](https://github.com/useplunk/plunk/commit/c278a0fd944977d4ea3a0c5a4a43ff4cc0a13aa3))
|
||||
* add configurable attachment limits for email service ([2eb4079](https://github.com/useplunk/plunk/commit/2eb407993bd5fed3e74be73445185e517c18b994)), closes [#358](https://github.com/useplunk/plunk/issues/358)
|
||||
* add middleware support for .md file rewrites ([0b3aa28](https://github.com/useplunk/plunk/commit/0b3aa28101d50a734945ce19b924f9aa30631c64))
|
||||
* add updateActiveProject function for in-place project updates ([6f31fa2](https://github.com/useplunk/plunk/commit/6f31fa2503b11e3a9a37d753086bbb74b42f7bc4))
|
||||
* enhance contact addition with bulk email lookup and subscription options ([bb72911](https://github.com/useplunk/plunk/commit/bb72911fa25e3a204095e74c07efbc261154ac30))
|
||||
* enhance contact data handling by filtering empty strings and allowing null to delete fields ([1094651](https://github.com/useplunk/plunk/commit/10946511b019ff76ce5f785a39cc0db4ea9ce820))
|
||||
* Enhance ease of use of workflow editor ([c910d20](https://github.com/useplunk/plunk/commit/c910d20bb71ff5de4abf95f3f96be9c435342cb4))
|
||||
* implement CommandPalette for enhanced navigation and recent pages tracking ([1c89ed0](https://github.com/useplunk/plunk/commit/1c89ed083e49148967713e9a9c2f7f8833db07c4))
|
||||
* implement meter event processing with queue for Stripe billing ([e048fc1](https://github.com/useplunk/plunk/commit/e048fc157080b36f40fbfc4440db51367a2d374b))
|
||||
* implement phishing detection using OpenRouter API with configurable sampling rate ([2da8b06](https://github.com/useplunk/plunk/commit/2da8b065d08438aa2adea397d8ab2a560e996af6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add in-memory cache for lower-confidence phishing checks ([208c809](https://github.com/useplunk/plunk/commit/208c809a90d0721261e643523b338d05e23e2bf9))
|
||||
* add new configuration options for phishing detection thresholds ([c0e0ad8](https://github.com/useplunk/plunk/commit/c0e0ad8bc6bfdddea5193191156d18aad440c2b0))
|
||||
* add project name and sender email parameters to phishing content check ([1637d54](https://github.com/useplunk/plunk/commit/1637d54e1b132dd6864fb1c50d23a388c1c47bf5))
|
||||
* add project name and sender email parameters to phishing content check ([15294a4](https://github.com/useplunk/plunk/commit/15294a40d88289fa8fd876c9e7c201eb1e2ca956))
|
||||
* Consistency across buttons and labels ([c1bcd35](https://github.com/useplunk/plunk/commit/c1bcd358cc611594d851414687c04bf7a03759a1))
|
||||
* Consistency across cards ([517753c](https://github.com/useplunk/plunk/commit/517753c420204510e06decec01db7749894a9265))
|
||||
* Content negotiation for xml and txt ([c8190b9](https://github.com/useplunk/plunk/commit/c8190b953f9f2e28e6cfa98e72d8dcfbba7e251b))
|
||||
* Harmonize rings and hover states ([f40cc86](https://github.com/useplunk/plunk/commit/f40cc86cec661899fb862b56ce3e35bf70b33e54))
|
||||
* improve layout and accessibility of workflow header and buttons ([ab3edd7](https://github.com/useplunk/plunk/commit/ab3edd7dc9030eeb770ffd64087476adf10d2d06))
|
||||
* log message for projects passing phishing checks ([8628754](https://github.com/useplunk/plunk/commit/862875428d21de03643e8e51126cc59abef95ccb))
|
||||
* prevent unnecessary state updates in search input effect ([98f1e80](https://github.com/useplunk/plunk/commit/98f1e80fca8253c125ba24c4d84c5be86841e1fa))
|
||||
* reconcile totalRecipients in CampaignService to prevent stuck campaigns ([4322de9](https://github.com/useplunk/plunk/commit/4322de929a84279370bedcbb4f6b7dcd81145aea)), closes [#348](https://github.com/useplunk/plunk/issues/348)
|
||||
* **ses:** emit List-Unsubscribe inside the header section, not the body ([11c428d](https://github.com/useplunk/plunk/commit/11c428d4ffe7c8aa87aac4d24148aaa95e01507a))
|
||||
* standardize step type labels and update visual styles in workflow components ([bdc9b30](https://github.com/useplunk/plunk/commit/bdc9b30ef29396870aa42515b8e688485a4673e8))
|
||||
* Sync display name in TemplateSearchPicker when initialName changes ([3649392](https://github.com/useplunk/plunk/commit/3649392b475271198cd61295091e8acf708efa25))
|
||||
* Sync display name in TemplateSearchPicker when initialName changes ([cbde3cc](https://github.com/useplunk/plunk/commit/cbde3cce3fcee382dae04e79a9480e67ad896725))
|
||||
* turn warning into success log ([b7eb254](https://github.com/useplunk/plunk/commit/b7eb2549d433552f32783b6fb8c192f5afb48ecd))
|
||||
* update phishing confidence threshold to 95% ([bb78c86](https://github.com/useplunk/plunk/commit/bb78c86c400b60fb0e84c7872e20ad6f7c43c199))
|
||||
* update project disabled messages for clarity and consistency ([d7eb85f](https://github.com/useplunk/plunk/commit/d7eb85ffdd99177c5e27104779281f5a08fe36a7))
|
||||
* update project icon colors for improved visibility and consistency ([5f741c6](https://github.com/useplunk/plunk/commit/5f741c66c7f0c9c25c45b26917ff68529d15f958))
|
||||
* update response format for phishing analysis and improve JSON parsing comment ([91d0d2d](https://github.com/useplunk/plunk/commit/91d0d2d297cb38fe0dfdfd7f73df355e2f51096a))
|
||||
|
||||
## [0.9.0](https://github.com/useplunk/plunk/compare/v0.8.0...v0.9.0) (2026-04-20)
|
||||
|
||||
|
||||
|
||||
@@ -154,6 +154,16 @@ Required for builds and deployment (see turbo.json and .env.example):
|
||||
plus-addressing, domain existence, and MX records
|
||||
- Security (optional): `AUTO_PROJECT_DISABLE` (default: true) - Controls whether projects are automatically disabled when
|
||||
bounce/complaint rate thresholds are exceeded
|
||||
- Attachment Limits (optional):
|
||||
- `MAX_ATTACHMENT_SIZE_MB` (default: 10) - Maximum total attachment size in megabytes per email. AWS SES supports up to 40 MB.
|
||||
- `MAX_ATTACHMENTS_COUNT` (default: 10) - Maximum number of attachments per email
|
||||
- Phishing Detection (optional):
|
||||
- `OPENROUTER_API_KEY` - API key for OpenRouter (enables phishing detection)
|
||||
- `OPENROUTER_MODEL` (default: anthropic/claude-3-haiku) - LLM model to use for content analysis
|
||||
- `PHISHING_DETECTION_SAMPLE_RATE` (default: 0.1) - Percentage of emails to check (0.0-1.0, e.g., 0.1 = 10%)
|
||||
- `PHISHING_CONFIDENCE_THRESHOLD` (default: 85) - Minimum confidence percentage (0-100) to auto-disable project for single detection
|
||||
- `PHISHING_CUMULATIVE_THRESHOLD` (default: 3) - Number of phishing detections within time window to trigger auto-disable
|
||||
- `PHISHING_CUMULATIVE_WINDOW_MS` (default: 3600000) - Time window in milliseconds for cumulative tracking (default 1 hour)
|
||||
|
||||
**Important Notes:**
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||

|
||||
|
||||
<h1 align="center">Plunk</h1>
|
||||
|
||||
@@ -15,23 +15,27 @@
|
||||
<a href="https://github.com/useplunk/plunk/network/members"><img src="https://img.shields.io/github/forks/useplunk/plunk" alt="Forks"/></a>
|
||||
<a href="https://github.com/useplunk/plunk/pkgs/container/plunk"><img src="https://img.shields.io/badge/docker-available-blue?logo=docker" alt="Docker"/></a>
|
||||
<a href="https://github.com/sponsors/driaug"><img src="https://img.shields.io/badge/sponsor-❤-ff69b4" alt="Sponsor"/></a>
|
||||
<a href="https://docs.useplunk.com"><img src="https://img.shields.io/badge/docs-docs.useplunk.com-black" alt="Documentation"/></a>
|
||||
<a href="https://useplunk.com/discord"><img src="https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white" alt="Discord"/></a>
|
||||
</p>
|
||||
|
||||
## Introduction
|
||||
|
||||
Plunk is an open-source email platform built on top of AWS SES. It allows you to easily send emails from your
|
||||
applications.
|
||||
It can be considered as a self-hosted alternative to services
|
||||
like [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
|
||||
Transactional emails, marketing campaigns, and workflow automation — in one platform. Self-hostable, $0.001 per email, no contact limits.
|
||||
|
||||
An open-source, self-hosted alternative to tools [SendGrid](https://sendgrid.com/), [Resend](https://resend.com) or [Mailgun](https://www.mailgun.com/).
|
||||
|
||||
## Features
|
||||
|
||||
- **Transactional Emails**: Send emails straight from your API with template support and variable substitution
|
||||
- **Campaigns**: Send newsletters and product updates to large audiences with segmentation
|
||||
- **SMTP**: Use Plunk as an SMTP relay to send emails from any existing tool or framework
|
||||
- **Campaigns**: Send newsletters and product updates to large audiences
|
||||
- **Workflows**: Create advanced automations with triggers, delays, and conditional logic
|
||||
- **Contact Management**: Organize contacts with custom fields and dynamic segmentation
|
||||
- **Segments**: Organize contacts with dynamic filtering and target the right audience
|
||||
- **Contact Management**: Manage contacts with custom fields and full activity history
|
||||
- **Analytics**: Track opens, clicks, bounces, and engagement metrics in real-time
|
||||
- **Custom Domains**: Verify and send from your own domains with DKIM/SPF support
|
||||
- **Inbound Emails**: Receive and process incoming emails with custom routing rules
|
||||
|
||||
## Sponsors
|
||||
|
||||
@@ -46,6 +50,11 @@ You can pull the latest image from [Github](https://github.com/useplunk/plunk/pk
|
||||
A complete guide on how to deploy Plunk can be found in
|
||||
the [documentation](https://docs.useplunk.com/self-hosting/introduction).
|
||||
|
||||
## Community
|
||||
|
||||
- **Documentation**: [docs.useplunk.com](https://docs.useplunk.com)
|
||||
- **Discord**: [useplunk.com/discord](https://useplunk.com/discord)
|
||||
|
||||
## Contributing
|
||||
|
||||
You are welcome to contribute to Plunk. You can find a guide on how to contribute in [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"mailparser": "^3.9.8",
|
||||
"morgan": "^1.10.0",
|
||||
"multer": "^2.1.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"signale": "^1.4.0",
|
||||
"stripe": "^20.0.0"
|
||||
},
|
||||
@@ -52,6 +53,7 @@
|
||||
"@types/mailparser": "^3.4.6",
|
||||
"@types/morgan": "^1.9.9",
|
||||
"@types/multer": "^2.0.0",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/signale": "^1.4.7",
|
||||
"concurrently": "^9.2.1",
|
||||
"tsx": "^4.20.6"
|
||||
|
||||
@@ -110,8 +110,24 @@ export const DISABLE_SIGNUPS = process.env.DISABLE_SIGNUPS === 'true';
|
||||
// Controls whether email validation checks are performed on signup (default: false)
|
||||
export const VERIFY_EMAIL_ON_SIGNUP = process.env.VERIFY_EMAIL_ON_SIGNUP === 'true';
|
||||
|
||||
// Attachment Limits (optional)
|
||||
// Maximum total attachment size in MB (default: 10). AWS SES supports up to 40 MB.
|
||||
export const MAX_ATTACHMENT_SIZE_MB = Number(validateEnv('MAX_ATTACHMENT_SIZE_MB', '10'));
|
||||
// Maximum number of attachments per email (default: 10)
|
||||
export const MAX_ATTACHMENTS_COUNT = Number(validateEnv('MAX_ATTACHMENTS_COUNT', '10'));
|
||||
|
||||
// Email Verification & Password Reset
|
||||
export const TOKEN_EXPIRY_SECONDS = 3600; // 1 hour
|
||||
export const EMAIL_VERIFICATION_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const PASSWORD_RESET_RATE_LIMIT = 3; // Max 3 emails per hour
|
||||
export const EMAIL_VERIFICATION_RATE_WINDOW = 3600; // 1 hour in seconds
|
||||
|
||||
// Phishing Detection (optional)
|
||||
// OpenRouter API integration for content safety checks
|
||||
export const OPENROUTER_API_KEY = validateEnv('OPENROUTER_API_KEY', '');
|
||||
export const OPENROUTER_MODEL = validateEnv('OPENROUTER_MODEL', 'anthropic/claude-3-haiku');
|
||||
export const PHISHING_DETECTION_SAMPLE_RATE = Number(validateEnv('PHISHING_DETECTION_SAMPLE_RATE', '0.1')); // Default 10% of emails
|
||||
export const PHISHING_DETECTION_ENABLED = OPENROUTER_API_KEY !== '';
|
||||
export const PHISHING_CONFIDENCE_THRESHOLD = Number(validateEnv('PHISHING_CONFIDENCE_THRESHOLD', '95')); // Confidence % to disable project (default 85%)
|
||||
export const PHISHING_CUMULATIVE_THRESHOLD = Number(validateEnv('PHISHING_CUMULATIVE_THRESHOLD', '3')); // Number of phishing detections before auto-disable (default 3)
|
||||
export const PHISHING_CUMULATIVE_WINDOW_MS = Number(validateEnv('PHISHING_CUMULATIVE_WINDOW_MS', '3600000')); // Time window for cumulative tracking in ms (default 1 hour)
|
||||
|
||||
@@ -120,7 +120,7 @@ export class Actions {
|
||||
* - data: object (optional) - Contact data and template variables
|
||||
* - Simple values are saved to contact (persistent)
|
||||
* - {value: any, persistent: false} are only used for this email (non-persistent)
|
||||
* - attachments: array (optional) - Email attachments (max 10, 10MB total)
|
||||
* - attachments: array (optional) - Email attachments (configurable via MAX_ATTACHMENTS_COUNT / MAX_ATTACHMENT_SIZE_MB, defaults: 10 / 10MB)
|
||||
* - filename: string (required) - Attachment filename
|
||||
* - content: string (required) - Base64 encoded file content
|
||||
* - contentType: string (required) - MIME type (e.g., "application/pdf")
|
||||
|
||||
@@ -273,6 +273,30 @@ export class Contacts {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/lookup
|
||||
* Bulk-check which emails already exist in the project (max 500)
|
||||
*/
|
||||
@Post('lookup')
|
||||
@Middleware([requireAuth, requireEmailVerified])
|
||||
@CatchAsync
|
||||
public async lookup(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const {emails} = req.body as {emails: string[]};
|
||||
|
||||
if (!Array.isArray(emails) || emails.length === 0) {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
if (emails.length > 500) {
|
||||
return res.status(400).json({error: 'Maximum 500 emails per lookup'});
|
||||
}
|
||||
|
||||
const result = await ContactService.lookup(auth.projectId!, emails);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /contacts/import
|
||||
* Import contacts from CSV file
|
||||
|
||||
@@ -52,7 +52,15 @@ export class Domains {
|
||||
const isDisabled = await SecurityService.isProjectDisabled(projectId);
|
||||
if (isDisabled) {
|
||||
throw new NotAllowed(
|
||||
'Cannot add domains to a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
);
|
||||
}
|
||||
|
||||
// Block subdomains whose root domain belongs to a disabled project
|
||||
const rootCheck = await DomainService.checkSubdomainOfDisabledRoot(domain);
|
||||
if (rootCheck.blocked) {
|
||||
throw new NotAllowed(
|
||||
'This domain cannot be added at this time. Please contact support for assistance.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -138,7 +146,7 @@ export class Domains {
|
||||
const isDisabled = await SecurityService.isProjectDisabled(domain.projectId);
|
||||
if (isDisabled) {
|
||||
throw new NotAllowed(
|
||||
'Cannot remove domains from a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -155,7 +155,7 @@ export class Segments {
|
||||
public async addMembers(req: Request, res: Response, _next: NextFunction) {
|
||||
const auth = res.locals.auth;
|
||||
const segmentId = req.params.id;
|
||||
const {emails} = req.body;
|
||||
const {emails, createMissing, subscribed} = req.body as {emails: string[]; createMissing?: boolean; subscribed?: boolean};
|
||||
|
||||
if (!segmentId) {
|
||||
return res.status(400).json({error: 'Segment ID is required'});
|
||||
@@ -165,7 +165,7 @@ export class Segments {
|
||||
return res.status(400).json({error: 'emails must be a non-empty array'});
|
||||
}
|
||||
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails);
|
||||
const result = await SegmentService.addContacts(auth.projectId!, segmentId, emails, createMissing ?? false, subscribed ?? true);
|
||||
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ export class Users {
|
||||
if (hasDisabledProject) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
`You cannot create new projects while you are a member of disabled projects: ${disabledProjectNames.join(', ')}. Please contact support to resolve security violations.`,
|
||||
`You cannot create new projects at this time. Please contact support for assistance.`,
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
@@ -615,7 +615,7 @@ export class Users {
|
||||
if (isDisabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot reset a disabled project. Please contact support to resolve security violations before making changes.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
@@ -703,7 +703,7 @@ export class Users {
|
||||
if (project.disabled) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Cannot delete a disabled project. Please contact support to resolve security violations.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {Prisma} from '@plunk/db';
|
||||
import {EmailSourceType, EmailStatus} from '@plunk/db';
|
||||
import type {Request, Response} from 'express';
|
||||
import {simpleParser} from 'mailparser';
|
||||
import sanitizeHtml from 'sanitize-html';
|
||||
import signale from 'signale';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
@@ -157,15 +158,33 @@ export class Webhooks {
|
||||
// Parse email content if available
|
||||
let htmlBody: string | undefined;
|
||||
|
||||
if (body.content) {
|
||||
if (body.content && typeof body.content === 'string') {
|
||||
try {
|
||||
const parsed = await simpleParser(body.content);
|
||||
// Prefer HTML body, fallback to text if no HTML available
|
||||
htmlBody = parsed.html ? String(parsed.html) : parsed.text || undefined;
|
||||
signale.info('[WEBHOOK] Email content parsed successfully');
|
||||
const isBase64 = body.receipt?.action?.encoding === 'BASE64';
|
||||
const emailBuffer = isBase64
|
||||
? Buffer.from(body.content, 'base64')
|
||||
: Buffer.from(body.content);
|
||||
|
||||
const parsed = await simpleParser(emailBuffer);
|
||||
const raw =
|
||||
(parsed.html ? String(parsed.html) : undefined) ??
|
||||
parsed.textAsHtml ??
|
||||
parsed.text ??
|
||||
undefined;
|
||||
|
||||
if (raw) {
|
||||
htmlBody = sanitizeHtml(raw, {
|
||||
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img']),
|
||||
allowedAttributes: {
|
||||
...sanitizeHtml.defaults.allowedAttributes,
|
||||
img: ['src', 'alt', 'width', 'height'],
|
||||
'*': ['style'],
|
||||
},
|
||||
allowedSchemes: ['http', 'https', 'mailto'],
|
||||
});
|
||||
}
|
||||
} catch (parseError) {
|
||||
signale.error('[WEBHOOK] Failed to parse email content:', parseError);
|
||||
// Continue processing without content
|
||||
}
|
||||
}
|
||||
|
||||
@@ -652,6 +671,86 @@ export class Webhooks {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'radar.early_fraud_warning.created': {
|
||||
const warning = event.data.object as Stripe.Radar.EarlyFraudWarning;
|
||||
const chargeId = typeof warning.charge === 'string' ? warning.charge : warning.charge?.id;
|
||||
|
||||
if (!chargeId) {
|
||||
signale.warn('[WEBHOOK] radar.early_fraud_warning.created missing charge ID');
|
||||
break;
|
||||
}
|
||||
|
||||
signale.warn(`[WEBHOOK] Early fraud warning received for charge ${chargeId} (${warning.fraud_type})`);
|
||||
|
||||
// Retrieve the charge to get card fingerprint and customer email
|
||||
const charge = await stripe.charges.retrieve(chargeId, {
|
||||
expand: ['payment_method_details', 'billing_details'],
|
||||
});
|
||||
|
||||
const cardFingerprint = charge.payment_method_details?.card?.fingerprint ?? null;
|
||||
const customerEmail = charge.billing_details?.email ?? null;
|
||||
|
||||
// Refund the charge
|
||||
try {
|
||||
await stripe.refunds.create({charge: chargeId});
|
||||
signale.success(`[WEBHOOK] Refunded charge ${chargeId} due to early fraud warning`);
|
||||
} catch (refundError) {
|
||||
signale.error(`[WEBHOOK] Failed to refund charge ${chargeId}:`, refundError);
|
||||
}
|
||||
|
||||
// Add card fingerprint and email to Stripe Radar blocklist value lists
|
||||
if (cardFingerprint) {
|
||||
try {
|
||||
const lists = await stripe.radar.valueLists.list({alias: 'blocked_card_fingerprints'});
|
||||
let listId: string;
|
||||
|
||||
const existingList = lists.data[0];
|
||||
if (existingList) {
|
||||
listId = existingList.id;
|
||||
} else {
|
||||
const newList = await stripe.radar.valueLists.create({
|
||||
alias: 'blocked_card_fingerprints',
|
||||
name: 'Blocked Card Fingerprints',
|
||||
item_type: 'card_fingerprint',
|
||||
});
|
||||
listId = newList.id;
|
||||
}
|
||||
|
||||
await stripe.radar.valueListItems.create({value_list: listId, value: cardFingerprint});
|
||||
signale.success(`[WEBHOOK] Added card fingerprint ${cardFingerprint} to Radar blocklist`);
|
||||
} catch (blocklistError) {
|
||||
signale.error(`[WEBHOOK] Failed to add card fingerprint to Radar blocklist:`, blocklistError);
|
||||
}
|
||||
}
|
||||
|
||||
if (customerEmail) {
|
||||
try {
|
||||
const emailLists = await stripe.radar.valueLists.list({alias: 'blocked_emails'});
|
||||
let emailListId: string;
|
||||
|
||||
const existingEmailList = emailLists.data[0];
|
||||
if (existingEmailList) {
|
||||
emailListId = existingEmailList.id;
|
||||
} else {
|
||||
const newList = await stripe.radar.valueLists.create({
|
||||
alias: 'blocked_emails',
|
||||
name: 'Blocked Emails',
|
||||
item_type: 'email',
|
||||
});
|
||||
emailListId = newList.id;
|
||||
}
|
||||
|
||||
await stripe.radar.valueListItems.create({value_list: emailListId, value: customerEmail});
|
||||
signale.success(`[WEBHOOK] Added email ${customerEmail} to Radar blocklist`);
|
||||
} catch (blocklistError) {
|
||||
signale.error(`[WEBHOOK] Failed to add email to Radar blocklist:`, blocklistError);
|
||||
}
|
||||
}
|
||||
|
||||
await NtfyService.notifyEarlyFraudWarning(chargeId, warning.fraud_type, cardFingerprint, customerEmail);
|
||||
break;
|
||||
}
|
||||
|
||||
// Unhandled events
|
||||
default:
|
||||
signale.info(`[WEBHOOK] Unhandled Stripe event type: ${event.type}`);
|
||||
|
||||
@@ -219,12 +219,17 @@ export class Workflows {
|
||||
const auth = res.locals.auth;
|
||||
const workflowId = req.params.id;
|
||||
const stepId = req.params.stepId;
|
||||
const splice = req.query.splice === 'true';
|
||||
|
||||
if (!workflowId || !stepId) {
|
||||
return res.status(400).json({error: 'Workflow ID and Step ID are required'});
|
||||
}
|
||||
|
||||
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
|
||||
if (splice) {
|
||||
await WorkflowService.spliceStep(auth.projectId!, workflowId, stepId);
|
||||
} else {
|
||||
await WorkflowService.deleteStep(auth.projectId!, workflowId, stepId);
|
||||
}
|
||||
|
||||
return res.status(204).send();
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {EmailService} from '../services/EmailService.js';
|
||||
import {EventService} from '../services/EventService.js';
|
||||
import {MeterService} from '../services/MeterService.js';
|
||||
import {emailQueue} from '../services/QueueService.js';
|
||||
import {SecurityService} from '../services/SecurityService.js';
|
||||
import {getSendingQuota, sendRawEmail} from '../services/SESService.js';
|
||||
|
||||
/**
|
||||
@@ -146,6 +147,36 @@ export async function createEmailWorker() {
|
||||
// Determine tracking based on project settings and email type
|
||||
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
|
||||
|
||||
// Check for phishing/dangerous content before sending
|
||||
const phishingCheck = await SecurityService.checkPhishingContent(
|
||||
email.projectId,
|
||||
email.project.name,
|
||||
email.from,
|
||||
formattedEmail.subject,
|
||||
compiledHtml,
|
||||
);
|
||||
|
||||
if (phishingCheck.shouldDisable) {
|
||||
// Disable project immediately
|
||||
await SecurityService.disableProjectForPhishing(
|
||||
email.projectId,
|
||||
formattedEmail.subject,
|
||||
phishingCheck.confidence,
|
||||
'Phishing content detected',
|
||||
);
|
||||
|
||||
// Mark email as failed
|
||||
await prisma.email.update({
|
||||
where: {id: emailId},
|
||||
data: {
|
||||
status: EmailStatus.FAILED,
|
||||
error: 'This email could not be sent. The project has been disabled. Please contact support.',
|
||||
},
|
||||
});
|
||||
|
||||
throw new Error(`Project ${email.projectId} has been disabled due to a policy violation`);
|
||||
}
|
||||
|
||||
// Send via AWS SES
|
||||
const result = await sendRawEmail({
|
||||
from: {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Background Job: Meter Processor
|
||||
* Reliably records Stripe billing meter events with automatic retries
|
||||
*/
|
||||
|
||||
import type {MeterEventJobData} from '@plunk/types';
|
||||
import {type Job, Worker} from 'bullmq';
|
||||
import signale from 'signale';
|
||||
|
||||
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {meterQueue} from '../services/QueueService.js';
|
||||
|
||||
async function processMeterEvent(job: Job<MeterEventJobData>): Promise<void> {
|
||||
const {customerId, value, idempotencyKey} = job.data;
|
||||
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return;
|
||||
}
|
||||
|
||||
await stripe.billing.meterEvents.create({
|
||||
event_name: STRIPE_METER_EVENT_NAME,
|
||||
payload: {
|
||||
stripe_customer_id: customerId,
|
||||
value: value.toString(),
|
||||
},
|
||||
...(idempotencyKey && {identifier: idempotencyKey}),
|
||||
});
|
||||
|
||||
signale.debug(`[METER-WORKER] Recorded ${value} email(s) for customer ${customerId}`);
|
||||
}
|
||||
|
||||
export function createMeterWorker(): Worker {
|
||||
const worker = new Worker<MeterEventJobData>(
|
||||
meterQueue.name,
|
||||
async (job: Job<MeterEventJobData>) => {
|
||||
await processMeterEvent(job);
|
||||
},
|
||||
{
|
||||
connection: meterQueue.opts.connection,
|
||||
concurrency: 5,
|
||||
limiter: {
|
||||
max: 50,
|
||||
duration: 1000, // Stay well within Stripe's rate limits
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
worker.on('failed', (job, error) => {
|
||||
signale.error(`[METER-WORKER] Job ${job?.id} failed (attempt ${job?.attemptsMade}):`, error);
|
||||
});
|
||||
|
||||
worker.on('error', error => {
|
||||
signale.error('[METER-WORKER] Worker error:', error);
|
||||
});
|
||||
|
||||
return worker;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {createCampaignWorker} from './campaign-processor.js';
|
||||
import {createDomainVerificationWorker} from './domain-verification-processor.js';
|
||||
import {createEmailWorker} from './email-processor.js';
|
||||
import {createImportWorker} from './import-processor.js';
|
||||
import {createMeterWorker} from './meter-processor.js';
|
||||
import {createScheduledCampaignWorker} from './scheduled-processor.js';
|
||||
import {createSegmentCountWorker} from './segment-count-processor.js';
|
||||
import {createWorkflowWorker} from './workflow-processor-queue.js';
|
||||
@@ -70,6 +71,11 @@ async function startWorkers() {
|
||||
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
|
||||
signale.success('[WORKER] API request cleanup worker started');
|
||||
|
||||
// Start meter worker
|
||||
const meterWorker = createMeterWorker();
|
||||
workers.push({name: 'meter', worker: meterWorker});
|
||||
signale.success('[WORKER] Meter worker started');
|
||||
|
||||
signale.success('[WORKER] All workers started successfully');
|
||||
} catch (error) {
|
||||
signale.error('[WORKER] Failed to start workers:', error);
|
||||
|
||||
@@ -135,7 +135,7 @@ export const requirePublicKey = async (req: Request, res: Response, next: NextFu
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
@@ -204,7 +204,7 @@ export const requireSecretKey = async (req: Request, res: Response, next: NextFu
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
@@ -271,7 +271,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
@@ -315,7 +315,7 @@ export const requireAuth = async (req: Request, res: Response, next: NextFunctio
|
||||
if (isWriteOperation) {
|
||||
throw new HttpException(
|
||||
403,
|
||||
'Project is disabled due to security violations. All write operations are blocked.',
|
||||
'This project has been disabled. Please contact support for assistance.',
|
||||
ErrorCode.PROJECT_DISABLED,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -505,6 +505,48 @@ export class CampaignService {
|
||||
limit,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
} else {
|
||||
// Last batch: reconcile totalRecipients to the actual number of emails created.
|
||||
// Dynamic segments re-evaluate on each batch query, so contacts that left the
|
||||
// segment after totalRecipients was calculated are silently skipped. Without this
|
||||
// reconciliation, sentCount can never reach the original totalRecipients and the
|
||||
// campaign remains stuck in SENDING forever.
|
||||
const [actualEmailCount, alreadySentCount] = await Promise.all([
|
||||
prisma.email.count({where: {campaignId}}),
|
||||
prisma.email.count({where: {campaignId, sentAt: {not: null}}}),
|
||||
]);
|
||||
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {totalRecipients: actualEmailCount},
|
||||
});
|
||||
|
||||
// If all emails were already processed by the time the last batch finished
|
||||
// (race: worker was faster than the batch chain), finalize the campaign now.
|
||||
if (actualEmailCount === 0 || alreadySentCount >= actualEmailCount) {
|
||||
const finalCampaign = await prisma.campaign.findUnique({
|
||||
where: {id: campaignId},
|
||||
include: {project: {select: {name: true}}},
|
||||
});
|
||||
|
||||
if (finalCampaign && finalCampaign.status === CampaignStatus.SENDING) {
|
||||
await prisma.campaign.update({
|
||||
where: {id: campaignId},
|
||||
data: {status: CampaignStatus.SENT, sentCount: alreadySentCount},
|
||||
});
|
||||
|
||||
signale.success(
|
||||
`[CAMPAIGN] Campaign ${finalCampaign.name} finalized after last batch: ${alreadySentCount}/${actualEmailCount} emails sent`,
|
||||
);
|
||||
|
||||
await NtfyService.notifyCampaignSendCompleted(
|
||||
finalCampaign.name,
|
||||
finalCampaign.project.name,
|
||||
finalCampaign.projectId,
|
||||
alreadySentCount,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -738,7 +780,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
// Use the SegmentService to build the where clause from the condition
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
const segmentWhere = await SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
@@ -781,7 +823,7 @@ export class CampaignService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const segmentWhere = SegmentService.buildConditionClause(condition);
|
||||
const segmentWhere = await SegmentService.buildConditionClause(condition);
|
||||
|
||||
return {
|
||||
...baseWhere,
|
||||
|
||||
@@ -76,6 +76,23 @@ export class ContactService {
|
||||
return contact;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-check which emails exist in the project — single query, safe for up to 500 addresses.
|
||||
*/
|
||||
public static async lookup(
|
||||
projectId: string,
|
||||
emails: string[],
|
||||
): Promise<{found: string[]; notFound: string[]}> {
|
||||
const rows = await prisma.contact.findMany({
|
||||
where: {projectId, email: {in: emails, mode: 'insensitive'}},
|
||||
select: {email: true},
|
||||
});
|
||||
const foundSet = new Set(rows.map(r => r.email.toLowerCase()));
|
||||
const found = emails.filter(e => foundSet.has(e.toLowerCase()));
|
||||
const notFound = emails.filter(e => !foundSet.has(e.toLowerCase()));
|
||||
return {found, notFound};
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a contact by email (returns null if not found)
|
||||
*/
|
||||
@@ -234,10 +251,22 @@ export class ContactService {
|
||||
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 !== null && value !== undefined && typeof value !== 'string') {
|
||||
if (value !== undefined && typeof value !== 'string') {
|
||||
throw new HttpException(400, 'Locale must be a string');
|
||||
}
|
||||
}
|
||||
@@ -265,33 +294,49 @@ export class ContactService {
|
||||
const isSubscriptionChanging = subscribed !== undefined && existing.subscribed !== subscribed;
|
||||
const wasSubscribed = existing.subscribed;
|
||||
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: existing.id},
|
||||
data: {
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
...(subscribed !== undefined ? {subscribed} : {}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
const updated = await prisma.contact.update({
|
||||
where: {id: existing.id},
|
||||
data: {
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
...(subscribed !== undefined ? {subscribed} : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Track subscription event if status changed
|
||||
if (isSubscriptionChanging) {
|
||||
if (subscribed && !wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
|
||||
} else if (!subscribed && wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
|
||||
// Track subscription event if status changed
|
||||
if (isSubscriptionChanging) {
|
||||
if (subscribed && !wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.subscribed', updated.id);
|
||||
} else if (!subscribed && wasSubscribed) {
|
||||
await EventService.trackEvent(projectId, 'contact.unsubscribed', updated.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return updated;
|
||||
return updated;
|
||||
} catch (error) {
|
||||
// Provide helpful error message for database/validation issues
|
||||
throw new HttpException(
|
||||
500,
|
||||
`Failed to update contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return prisma.contact.create({
|
||||
data: {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
try {
|
||||
return await prisma.contact.create({
|
||||
data: {
|
||||
projectId,
|
||||
email,
|
||||
data: Object.keys(mergedData).length > 0 ? toPrismaJson(mergedData) : Prisma.JsonNull,
|
||||
subscribed: subscribed ?? defaultSubscribed,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
// Provide helpful error message for database/validation issues
|
||||
throw new HttpException(
|
||||
500,
|
||||
`Failed to create contact: ${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -388,6 +388,49 @@ export class DomainService {
|
||||
return domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const parts = domain.split('.');
|
||||
return parts.length > 2 ? parts.slice(-2).join('.') : domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether the root domain of `domain` is owned by a disabled project.
|
||||
* Prevents subdomains from being added when the parent domain is flagged.
|
||||
*/
|
||||
public static async checkSubdomainOfDisabledRoot(
|
||||
domain: string,
|
||||
): Promise<{blocked: boolean; projectName?: string; projectId?: string}> {
|
||||
const root = this.rootDomain(domain);
|
||||
|
||||
// Only relevant when the submitted domain is actually a subdomain
|
||||
if (root === domain) {
|
||||
return {blocked: false};
|
||||
}
|
||||
|
||||
const rootDomainRecord = await prisma.domain.findFirst({
|
||||
where: {domain: root},
|
||||
include: {
|
||||
project: {
|
||||
select: {id: true, name: true, disabled: true},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (rootDomainRecord?.project.disabled) {
|
||||
return {
|
||||
blocked: true,
|
||||
projectName: rootDomainRecord.project.name,
|
||||
projectId: rootDomainRecord.project.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {blocked: false};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a domain is already linked to another project
|
||||
* Used when adding a new domain to verify if the user has access to the existing project
|
||||
|
||||
@@ -1,98 +1,28 @@
|
||||
import signale from 'signale';
|
||||
|
||||
import {STRIPE_ENABLED, STRIPE_METER_EVENT_NAME} from '../app/constants.js';
|
||||
import {stripe} from '../app/stripe.js';
|
||||
import {STRIPE_ENABLED} from '../app/constants.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
|
||||
/**
|
||||
* Meter Service
|
||||
* Handles recording usage events to Stripe billing meters for pay-as-you-go pricing
|
||||
*/
|
||||
export class MeterService {
|
||||
/**
|
||||
* Record an email sent event to Stripe meter
|
||||
* This is used for usage-based billing where customers pay per email sent
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param value - Number of emails sent (default: 1)
|
||||
* @param idempotencyKey - Optional unique identifier to prevent duplicate recording
|
||||
*/
|
||||
public static async recordEmailSent(customerId: string, value = 1, idempotencyKey?: string): Promise<void> {
|
||||
// Skip if billing is disabled (self-hosted mode)
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
if (!STRIPE_ENABLED) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no customer ID (not subscribed)
|
||||
if (!customerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await stripe.billing.meterEvents.create({
|
||||
event_name: STRIPE_METER_EVENT_NAME,
|
||||
payload: {
|
||||
stripe_customer_id: customerId,
|
||||
value: value.toString(), // Must be a string
|
||||
},
|
||||
...(idempotencyKey && {identifier: idempotencyKey}),
|
||||
});
|
||||
|
||||
signale.debug(`[METER] Recorded ${value} email(s) sent for customer ${customerId}`);
|
||||
await QueueService.queueMeterEvent(customerId, value, idempotencyKey);
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we don't want billing issues to break email sending
|
||||
signale.error('[METER] Failed to record email sent event:', error);
|
||||
|
||||
// If it's a rate limit error, we might want to retry
|
||||
if (error instanceof Error && error.message.includes('429')) {
|
||||
signale.warn('[METER] Rate limited - consider implementing queue-based meter recording');
|
||||
}
|
||||
signale.error('[METER] Failed to enqueue meter event:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record multiple emails in a single batch (for campaign sending)
|
||||
* More efficient than individual calls when sending bulk emails
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param count - Number of emails sent in this batch
|
||||
* @param batchId - Unique identifier for this batch
|
||||
*/
|
||||
public static async recordEmailBatch(customerId: string, count: number, batchId: string): Promise<void> {
|
||||
if (count <= 0) return;
|
||||
|
||||
await this.recordEmailSent(customerId, count, `batch_${batchId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current usage for a customer in the current billing period
|
||||
* Useful for displaying usage dashboards or implementing soft limits
|
||||
*
|
||||
* @param customerId - Stripe customer ID
|
||||
* @param meterId - The meter ID from Stripe dashboard
|
||||
* @param startTime - Start of period (Unix timestamp)
|
||||
* @param endTime - End of period (Unix timestamp)
|
||||
*/
|
||||
public static async getUsageSummary(
|
||||
meterId: string,
|
||||
customerId: string,
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): Promise<unknown> {
|
||||
if (!STRIPE_ENABLED || !stripe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const summaries = await stripe.billing.meters.listEventSummaries(meterId, {
|
||||
customer: customerId,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
});
|
||||
|
||||
return summaries;
|
||||
} catch (error) {
|
||||
signale.error('[METER] Failed to get usage summary:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,6 +799,28 @@ export class NtfyService {
|
||||
});
|
||||
}
|
||||
|
||||
public static async notifyEarlyFraudWarning(
|
||||
chargeId: string,
|
||||
fraudType: string,
|
||||
cardFingerprint: string | null,
|
||||
customerEmail: string | null,
|
||||
): Promise<void> {
|
||||
const details = [
|
||||
`Charge: ${chargeId}`,
|
||||
`Fraud type: ${fraudType}`,
|
||||
cardFingerprint ? `Card fingerprint: ${cardFingerprint}` : null,
|
||||
customerEmail ? `Customer: ${customerEmail}` : null,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ');
|
||||
|
||||
await this.sendUrgent('Stripe Early Fraud Warning', `Refunded and blocklisted. ${details}`, [
|
||||
NtfyTag.SHIELD,
|
||||
NtfyTag.MONEY,
|
||||
NtfyTag.SKULL,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the ntfy service with the configured URL
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
CampaignBatchJobData,
|
||||
ContactImportJobData,
|
||||
DomainVerificationJobData,
|
||||
MeterEventJobData,
|
||||
ScheduledCampaignJobData,
|
||||
SegmentCountJobData,
|
||||
SendEmailJobData,
|
||||
@@ -158,6 +159,19 @@ export const bulkContactQueue = new Queue<BulkContactActionJobData>('bulk-contac
|
||||
},
|
||||
});
|
||||
|
||||
export const meterQueue = new Queue<MeterEventJobData>('meter', {
|
||||
connection: redisConnection,
|
||||
defaultJobOptions: {
|
||||
attempts: 10,
|
||||
backoff: {
|
||||
type: 'exponential',
|
||||
delay: 5000,
|
||||
},
|
||||
removeOnComplete: 5000,
|
||||
removeOnFail: 10000,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Queue Service - Centralized queue management
|
||||
*/
|
||||
@@ -313,6 +327,23 @@ export class QueueService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue a Stripe meter event for reliable delivery with retries
|
||||
*/
|
||||
public static async queueMeterEvent(
|
||||
customerId: string,
|
||||
value: number,
|
||||
idempotencyKey?: string,
|
||||
): Promise<Job<MeterEventJobData>> {
|
||||
return meterQueue.add(
|
||||
'record-meter-event',
|
||||
{customerId, value, idempotencyKey},
|
||||
{
|
||||
jobId: idempotencyKey ? `meter-${idempotencyKey}` : undefined,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Queue bulk contact action job
|
||||
*/
|
||||
@@ -390,6 +421,7 @@ export class QueueService {
|
||||
domainVerificationCounts,
|
||||
apiRequestCleanupCounts,
|
||||
bulkContactCounts,
|
||||
meterCounts,
|
||||
] = await Promise.all([
|
||||
emailQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
campaignQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
@@ -400,6 +432,7 @@ export class QueueService {
|
||||
domainVerificationQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
apiRequestCleanupQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
bulkContactQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
meterQueue.getJobCounts('waiting', 'active', 'completed', 'failed', 'delayed'),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -412,6 +445,7 @@ export class QueueService {
|
||||
domainVerification: domainVerificationCounts,
|
||||
apiRequestCleanup: apiRequestCleanupCounts,
|
||||
bulkContact: bulkContactCounts,
|
||||
meter: meterCounts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -429,6 +463,7 @@ export class QueueService {
|
||||
domainVerificationQueue.pause(),
|
||||
apiRequestCleanupQueue.pause(),
|
||||
bulkContactQueue.pause(),
|
||||
meterQueue.pause(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -446,6 +481,7 @@ export class QueueService {
|
||||
domainVerificationQueue.resume(),
|
||||
apiRequestCleanupQueue.resume(),
|
||||
bulkContactQueue.resume(),
|
||||
meterQueue.resume(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -472,6 +508,8 @@ export class QueueService {
|
||||
domainVerificationQueue.clean(gracePeriod * 7, 50, 'failed'),
|
||||
bulkContactQueue.clean(gracePeriod, 50, 'completed'),
|
||||
bulkContactQueue.clean(gracePeriod * 7, 100, 'failed'),
|
||||
meterQueue.clean(gracePeriod * 30, 5000, 'completed'), // Keep 30 days for billing audit
|
||||
meterQueue.clean(gracePeriod * 30, 10000, 'failed'),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -557,6 +595,7 @@ export class QueueService {
|
||||
domainVerificationQueue.close(),
|
||||
apiRequestCleanupQueue.close(),
|
||||
bulkContactQueue.close(),
|
||||
meterQueue.close(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,21 +133,23 @@ export async function sendRawEmail({
|
||||
rootContentType = `multipart/related; boundary="${relatedBoundary}"`;
|
||||
}
|
||||
|
||||
// Build the additional headers (custom headers + List-Unsubscribe), filtering
|
||||
// out empties so we never emit a blank line inside the header section.
|
||||
// Per RFC 5322 §2.1, a blank line terminates the header section, so any blank
|
||||
// line here would push subsequent headers (notably List-Unsubscribe) into the body.
|
||||
const extraHeaderLines = [
|
||||
...(headers ? Object.entries(headers).map(([key, value]) => `${key}: ${value}`) : []),
|
||||
...(unsubscribeHeader ? [unsubscribeHeader] : []),
|
||||
];
|
||||
const extraHeaders = extraHeaderLines.length > 0 ? `\n${extraHeaderLines.join('\n')}` : '';
|
||||
|
||||
// Build raw MIME message
|
||||
let rawMessage = `From: ${from.name} <${from.email}>
|
||||
To: ${toHeader}
|
||||
Reply-To: ${reply || from.email}
|
||||
Subject: ${content.subject}
|
||||
MIME-Version: 1.0
|
||||
Content-Type: ${rootContentType}
|
||||
${
|
||||
headers
|
||||
? Object.entries(headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join('\n')
|
||||
: ''
|
||||
}
|
||||
${unsubscribeHeader}
|
||||
Content-Type: ${rootContentType}${extraHeaders}
|
||||
|
||||
`;
|
||||
|
||||
|
||||
@@ -10,7 +10,18 @@ import {Keys} from './keys.js';
|
||||
import {MembershipService} from './MembershipService.js';
|
||||
import {NtfyService} from './NtfyService.js';
|
||||
import {QueueService} from './QueueService.js';
|
||||
import {AUTO_PROJECT_DISABLE, DASHBOARD_URI, LANDING_URI} from '../app/constants.js';
|
||||
import {
|
||||
AUTO_PROJECT_DISABLE,
|
||||
DASHBOARD_URI,
|
||||
LANDING_URI,
|
||||
OPENROUTER_API_KEY,
|
||||
OPENROUTER_MODEL,
|
||||
PHISHING_CONFIDENCE_THRESHOLD,
|
||||
PHISHING_CUMULATIVE_THRESHOLD,
|
||||
PHISHING_CUMULATIVE_WINDOW_MS,
|
||||
PHISHING_DETECTION_ENABLED,
|
||||
PHISHING_DETECTION_SAMPLE_RATE,
|
||||
} from '../app/constants.js';
|
||||
|
||||
/**
|
||||
* Security thresholds for bounce and complaint rates
|
||||
@@ -66,6 +77,58 @@ const SECURITY_THRESHOLDS = {
|
||||
NEW_PROJECT_COMPLAINT_7DAY_CEILING_CRITICAL: 20,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Redis-based tracking for phishing detections per project
|
||||
* Tracks timestamp and confidence of recent phishing detections
|
||||
* Uses sorted sets for efficient time-based filtering
|
||||
*/
|
||||
interface PhishingDetection {
|
||||
timestamp: number;
|
||||
confidence: number;
|
||||
subject: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track a phishing detection for cumulative analysis using Redis
|
||||
*/
|
||||
async function trackPhishingDetection(projectId: string, confidence: number, subject: string): Promise<void> {
|
||||
const now = Date.now();
|
||||
const key = `phishing:detections:${projectId}`;
|
||||
|
||||
// Store detection as sorted set member (score = timestamp)
|
||||
// Value is JSON with confidence and subject
|
||||
const detection: PhishingDetection = {timestamp: now, confidence, subject};
|
||||
await redis.zadd(key, now, JSON.stringify(detection));
|
||||
|
||||
// Remove detections outside the time window
|
||||
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
|
||||
await redis.zremrangebyscore(key, '-inf', cutoff);
|
||||
|
||||
// Set TTL to window duration to auto-cleanup old keys
|
||||
await redis.expire(key, Math.ceil(PHISHING_CUMULATIVE_WINDOW_MS / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of recent phishing detections for a project from Redis
|
||||
*/
|
||||
async function getRecentPhishingCount(projectId: string): Promise<number> {
|
||||
const now = Date.now();
|
||||
const cutoff = now - PHISHING_CUMULATIVE_WINDOW_MS;
|
||||
const key = `phishing:detections:${projectId}`;
|
||||
|
||||
// Count detections within the time window
|
||||
const count = await redis.zcount(key, cutoff, '+inf');
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear phishing detection history for a project (e.g., after disable)
|
||||
*/
|
||||
async function clearPhishingHistory(projectId: string): Promise<void> {
|
||||
const key = `phishing:detections:${projectId}`;
|
||||
await redis.del(key);
|
||||
}
|
||||
|
||||
interface RateData {
|
||||
total: number;
|
||||
bounces: number;
|
||||
@@ -689,7 +752,7 @@ export class SecurityService {
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled - Security Risk', template)),
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
@@ -699,4 +762,258 @@ export class SecurityService {
|
||||
signale.error(`[SECURITY] Failed to disable project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check email content for phishing or dangerous content using LLM
|
||||
* Uses sampling to reduce costs - only checks a percentage of emails
|
||||
* @returns { isPhishing: boolean, confidence: number, shouldDisable: boolean }
|
||||
*/
|
||||
public static async checkPhishingContent(
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
fromEmail: string,
|
||||
subject: string,
|
||||
body: string,
|
||||
): Promise<{isPhishing: boolean; confidence: number; shouldDisable: boolean}> {
|
||||
// Default safe response
|
||||
const safeResponse = {isPhishing: false, confidence: 0, shouldDisable: false};
|
||||
|
||||
try {
|
||||
// Check if phishing detection is enabled
|
||||
if (!PHISHING_DETECTION_ENABLED) {
|
||||
return safeResponse;
|
||||
}
|
||||
|
||||
// Sample-based checking - only check a percentage of emails
|
||||
if (Math.random() > PHISHING_DETECTION_SAMPLE_RATE) {
|
||||
return safeResponse;
|
||||
}
|
||||
|
||||
signale.info(`[PHISHING] Checking email for project ${projectId} (sampled)`);
|
||||
|
||||
// Strip HTML tags for content analysis (basic HTML removal)
|
||||
const strippedBody = body
|
||||
.replace(/<[^>]*>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
// Extract URLs from the original HTML body for domain analysis
|
||||
const urlMatches = body.match(/https?:\/\/[^\s"'<>]+/g) ?? [];
|
||||
const uniqueUrls = [...new Set(urlMatches.map(u => u.replace(/[.,;)]+$/, '')))].slice(0, 20);
|
||||
|
||||
// Extract sender domain for context
|
||||
const senderDomain = fromEmail.includes('@') ? fromEmail.split('@')[1] : fromEmail;
|
||||
|
||||
// Call OpenRouter API
|
||||
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${OPENROUTER_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://useplunk.com',
|
||||
'X-Title': 'Plunk Email Platform',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: OPENROUTER_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: `You are a security expert analyzing emails for phishing, scams, and dangerous content. Analyze the email subject and body and respond with a JSON object in this exact format:
|
||||
{
|
||||
"is_phishing": true/false,
|
||||
"confidence": 0-100,
|
||||
"reason": "brief explanation"
|
||||
}
|
||||
|
||||
Criteria for phishing/dangerous content:
|
||||
- Credential theft attempts (fake login pages, password requests)
|
||||
- Financial scams (fake invoices, lottery scams, advance-fee fraud)
|
||||
- Malicious links or attachments references
|
||||
- Impersonation of banks, government, or well-known companies
|
||||
- Urgent threats or fear-based manipulation
|
||||
- Suspicious cryptocurrency or investment schemes
|
||||
- Requests for sensitive personal information
|
||||
|
||||
IMPORTANT - Use sender and project context when evaluating:
|
||||
- The sender project name and domain are provided. Links to the sender's own domain(s) are expected and NOT suspicious.
|
||||
- URLs that match or are clearly related to the project name or sender domain add credibility.
|
||||
- Only flag a URL as suspicious if it is unrelated to or impersonates a different known brand.
|
||||
- Lack of recognizable brand does NOT make an email phishing — many legitimate businesses are not famous.
|
||||
|
||||
Be strict but fair. Marketing emails and legitimate transactional emails are NOT phishing.
|
||||
Only flag content that is CLEARLY attempting to deceive or harm recipients.
|
||||
Set confidence to 100 only if you are absolutely certain it's phishing.`,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Sender project name: ${projectName}
|
||||
Sender domain: ${senderDomain}
|
||||
${uniqueUrls.length > 0 ? `URLs found in email: ${uniqueUrls.join(', ')}` : ''}
|
||||
|
||||
Subject: ${subject}
|
||||
|
||||
Body:
|
||||
${strippedBody.substring(0, 2000)}`,
|
||||
},
|
||||
],
|
||||
temperature: 0.1,
|
||||
max_tokens: 200,
|
||||
response_format: {type: 'json_object'},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
signale.error(`[PHISHING] OpenRouter API error: ${response.status} ${response.statusText}`);
|
||||
return safeResponse;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: Array<{message?: {content?: string}}>;
|
||||
};
|
||||
|
||||
const content = data.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
signale.error('[PHISHING] No content in OpenRouter response');
|
||||
return safeResponse;
|
||||
}
|
||||
|
||||
// Parse the JSON response (response_format ensures clean JSON)
|
||||
const result = JSON.parse(content) as {
|
||||
is_phishing: boolean;
|
||||
confidence: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
const isPhishing = result.is_phishing === true;
|
||||
const confidence = Math.min(100, Math.max(0, result.confidence || 0));
|
||||
|
||||
if (isPhishing) {
|
||||
signale.warn(
|
||||
`[PHISHING] Detected phishing content for project ${projectId} - Confidence: ${confidence}% - Reason: ${result.reason}`,
|
||||
);
|
||||
|
||||
// Track this detection for cumulative analysis
|
||||
await trackPhishingDetection(projectId, confidence, subject);
|
||||
|
||||
// Get count of recent detections
|
||||
const recentCount = await getRecentPhishingCount(projectId);
|
||||
signale.info(
|
||||
`[PHISHING] Project ${projectId} has ${recentCount} phishing detection(s) in the last ${PHISHING_CUMULATIVE_WINDOW_MS / 1000 / 60} minutes`,
|
||||
);
|
||||
} else {
|
||||
signale.success(`[PHISHING] Passed phishing check for project: ${projectId}`);
|
||||
}
|
||||
|
||||
// Determine if project should be disabled
|
||||
// Disable if EITHER:
|
||||
// 1. Single detection with high confidence (>= threshold)
|
||||
// 2. Multiple detections within time window (>= cumulative threshold)
|
||||
const meetsConfidenceThreshold = isPhishing && confidence >= PHISHING_CONFIDENCE_THRESHOLD;
|
||||
const recentCount = await getRecentPhishingCount(projectId);
|
||||
const meetsCumulativeThreshold = isPhishing && recentCount >= PHISHING_CUMULATIVE_THRESHOLD;
|
||||
const shouldDisable = meetsConfidenceThreshold || meetsCumulativeThreshold;
|
||||
|
||||
if (shouldDisable) {
|
||||
if (meetsConfidenceThreshold) {
|
||||
signale.error(
|
||||
`[PHISHING] High confidence phishing detected (${confidence}% >= ${PHISHING_CONFIDENCE_THRESHOLD}%) - will disable project ${projectId}`,
|
||||
);
|
||||
}
|
||||
if (meetsCumulativeThreshold) {
|
||||
signale.error(
|
||||
`[PHISHING] Cumulative threshold reached (${recentCount} >= ${PHISHING_CUMULATIVE_THRESHOLD} detections) - will disable project ${projectId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear history after disabling
|
||||
await clearPhishingHistory(projectId);
|
||||
}
|
||||
|
||||
return {
|
||||
isPhishing,
|
||||
confidence,
|
||||
shouldDisable,
|
||||
};
|
||||
} catch (error) {
|
||||
// Log error but don't throw - we don't want phishing checks to break email sending
|
||||
// Better to let a phishing email through than to break legitimate emails
|
||||
signale.error(`[PHISHING] Failed to check content for project ${projectId}:`, error);
|
||||
return safeResponse;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable a project due to phishing detection
|
||||
*/
|
||||
public static async disableProjectForPhishing(
|
||||
projectId: string,
|
||||
subject: string,
|
||||
confidence: number,
|
||||
reason?: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Check if already disabled to avoid duplicate logs
|
||||
const project = await prisma.project.findUnique({
|
||||
where: {id: projectId},
|
||||
select: {id: true, disabled: true, name: true},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
signale.error(`[PHISHING] Project ${projectId} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (project.disabled) {
|
||||
signale.warn(`[PHISHING] Project ${projectId} (${project.name}) already disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable the project
|
||||
await prisma.project.update({
|
||||
where: {id: projectId},
|
||||
data: {disabled: true},
|
||||
});
|
||||
|
||||
const violation = `A policy violation was detected. Please contact support for more details.`;
|
||||
|
||||
// Log critical security event
|
||||
signale.error(
|
||||
`[PHISHING] Project ${projectId} (${project.name}) has been automatically disabled due to phishing detection:`,
|
||||
violation,
|
||||
);
|
||||
|
||||
// Cancel all pending jobs for this project
|
||||
try {
|
||||
await QueueService.cancelAllProjectJobs(projectId);
|
||||
signale.info(`[PHISHING] Cancelled all pending jobs for project ${projectId}`);
|
||||
} catch (error) {
|
||||
signale.error(`[PHISHING] Failed to cancel pending jobs for project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
// Send urgent notification about project suspension
|
||||
await NtfyService.notifyProjectDisabledForSecurity(project.name, projectId, [violation]);
|
||||
|
||||
// Send email notification to project members
|
||||
try {
|
||||
const members = await MembershipService.getMembers(projectId);
|
||||
const emails = members.map(m => m.email);
|
||||
if (emails.length > 0) {
|
||||
const template = React.createElement(ProjectDisabledEmail, {
|
||||
projectName: project.name,
|
||||
projectId,
|
||||
violations: [violation],
|
||||
dashboardUrl: DASHBOARD_URI,
|
||||
landingUrl: LANDING_URI,
|
||||
});
|
||||
await Promise.all(
|
||||
emails.map(email => sendPlatformEmail(email, 'Project Disabled', template)),
|
||||
);
|
||||
}
|
||||
} catch (emailError) {
|
||||
signale.error(`[PHISHING] Failed to send project disabled email:`, emailError);
|
||||
}
|
||||
} catch (error) {
|
||||
signale.error(`[PHISHING] Failed to disable project ${projectId}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
|
||||
const [contacts, total] = await Promise.all([
|
||||
prisma.contact.findMany({
|
||||
@@ -137,7 +137,7 @@ export class SegmentService {
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
// Compute initial member count
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const where = await this.buildWhereClause(projectId, data.condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
conditionJson = toPrismaJson(data.condition);
|
||||
}
|
||||
@@ -192,10 +192,14 @@ export class SegmentService {
|
||||
if (data.condition !== undefined && existing.type !== 'STATIC') {
|
||||
// Validate condition if provided (only for DYNAMIC segments)
|
||||
this.validateCondition(data.condition);
|
||||
|
||||
if (this.getReferencedSegmentIds(data.condition).has(segmentId)) {
|
||||
throw new HttpException(400, 'A segment cannot reference itself');
|
||||
}
|
||||
updateData.condition = toPrismaJson(data.condition);
|
||||
|
||||
// Recompute member count when condition changes
|
||||
const where = this.buildWhereClause(projectId, data.condition);
|
||||
const where = await this.buildWhereClause(projectId, data.condition);
|
||||
updateData.memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
if (data.trackMembership !== undefined) {
|
||||
@@ -267,7 +271,7 @@ export class SegmentService {
|
||||
memberCount = await prisma.segmentMembership.count({where: {segmentId, exitedAt: null}});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
@@ -305,7 +309,7 @@ export class SegmentService {
|
||||
});
|
||||
} else {
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
memberCount = await prisma.contact.count({where});
|
||||
}
|
||||
|
||||
@@ -328,7 +332,9 @@ export class SegmentService {
|
||||
projectId: string,
|
||||
segmentId: string,
|
||||
emails: string[],
|
||||
): Promise<{added: number; notFound: string[]}> {
|
||||
createMissing = false,
|
||||
subscribed = true,
|
||||
): Promise<{added: number; created: number; notFound: string[]}> {
|
||||
const segment = await this.get(projectId, segmentId);
|
||||
|
||||
if (segment.type !== 'STATIC') {
|
||||
@@ -336,7 +342,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
// Look up contacts by email (case-insensitive)
|
||||
const contacts = await prisma.contact.findMany({
|
||||
let contacts = await prisma.contact.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
email: {in: emails, mode: 'insensitive'},
|
||||
@@ -344,7 +350,24 @@ export class SegmentService {
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
|
||||
const foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
let foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
const missing = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||
|
||||
let created = 0;
|
||||
if (createMissing && missing.length > 0) {
|
||||
await prisma.contact.createMany({
|
||||
data: missing.map(email => ({projectId, email, subscribed})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
created = missing.length;
|
||||
// Re-fetch to include newly created contacts
|
||||
contacts = await prisma.contact.findMany({
|
||||
where: {projectId, email: {in: emails, mode: 'insensitive'}},
|
||||
select: {id: true, email: true},
|
||||
});
|
||||
foundEmails = new Set(contacts.map(c => c.email.toLowerCase()));
|
||||
}
|
||||
|
||||
const notFound = emails.filter(e => !foundEmails.has(e.toLowerCase()));
|
||||
|
||||
if (contacts.length > 0) {
|
||||
@@ -377,7 +400,7 @@ export class SegmentService {
|
||||
await prisma.segment.update({where: {id: segmentId}, data: {memberCount}});
|
||||
}
|
||||
|
||||
return {added: contacts.length, notFound};
|
||||
return {added: contacts.length, created, notFound};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -441,7 +464,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
const condition = fromPrismaJson<FilterCondition>(segment.condition);
|
||||
const where = this.buildWhereClause(projectId, condition);
|
||||
const where = await this.buildWhereClause(projectId, condition);
|
||||
|
||||
// Get all matching contacts using cursor-based pagination to avoid memory issues
|
||||
const BATCH_SIZE = 1000;
|
||||
@@ -610,9 +633,57 @@ export class SegmentService {
|
||||
/**
|
||||
* Build a single filter condition
|
||||
*/
|
||||
public static buildFilterCondition(filter: SegmentFilter): Prisma.ContactWhereInput {
|
||||
public static async buildFilterCondition(
|
||||
filter: SegmentFilter,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const {field, operator, value, unit} = filter;
|
||||
|
||||
// Handle segment membership filters (e.g., "segment.<segmentId>")
|
||||
if (field.startsWith('segment.')) {
|
||||
const segmentId = field.substring(8);
|
||||
|
||||
if (visitedSegments.has(segmentId)) {
|
||||
throw new HttpException(400, 'Circular segment reference detected');
|
||||
}
|
||||
|
||||
const referencedSegment = await prisma.segment.findUnique({
|
||||
where: {id: segmentId},
|
||||
});
|
||||
|
||||
if (!referencedSegment) {
|
||||
throw new HttpException(400, `Referenced segment not found: ${segmentId}`);
|
||||
}
|
||||
|
||||
let memberIds: string[];
|
||||
|
||||
if (referencedSegment.type === 'STATIC' || referencedSegment.trackMembership) {
|
||||
// Use the membership table for static or tracked segments
|
||||
const memberships = await prisma.segmentMembership.findMany({
|
||||
where: {segmentId, exitedAt: null},
|
||||
select: {contactId: true},
|
||||
});
|
||||
memberIds = memberships.map(m => m.contactId);
|
||||
} else {
|
||||
// For untracked dynamic segments, evaluate the condition recursively
|
||||
const nestedCondition = fromPrismaJson<FilterCondition>(referencedSegment.condition);
|
||||
const nextVisited = new Set(visitedSegments).add(segmentId);
|
||||
const nestedWhere = await this.buildConditionClause(nestedCondition, nextVisited);
|
||||
|
||||
if (operator === 'memberOfSegment') {
|
||||
return nestedWhere;
|
||||
}
|
||||
|
||||
return {NOT: nestedWhere};
|
||||
}
|
||||
|
||||
if (operator === 'memberOfSegment') {
|
||||
return {id: {in: memberIds}};
|
||||
}
|
||||
|
||||
return {id: {notIn: memberIds}};
|
||||
}
|
||||
|
||||
// Handle event-based filters (e.g., "event.upgrade", "event.purchase")
|
||||
if (field.startsWith('event.')) {
|
||||
const eventName = field.substring(6); // Remove "event." prefix
|
||||
@@ -645,6 +716,26 @@ export class SegmentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all segment IDs directly referenced in a condition (non-recursive DB lookup)
|
||||
*/
|
||||
private static getReferencedSegmentIds(condition: FilterCondition): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const group of condition.groups) {
|
||||
for (const filter of group.filters) {
|
||||
if (filter.field.startsWith('segment.')) {
|
||||
ids.add(filter.field.substring(8));
|
||||
}
|
||||
}
|
||||
if (group.conditions) {
|
||||
for (const id of this.getReferencedSegmentIds(group.conditions)) {
|
||||
ids.add(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate segment condition (recursive)
|
||||
*/
|
||||
@@ -669,8 +760,13 @@ export class SegmentService {
|
||||
/**
|
||||
* Build Prisma clause from filter condition (recursive)
|
||||
*/
|
||||
public static buildConditionClause(condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
const groupClauses = condition.groups.map(group => this.buildGroupClause(group));
|
||||
public static async buildConditionClause(
|
||||
condition: FilterCondition,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const groupClauses = await Promise.all(
|
||||
condition.groups.map(group => this.buildGroupClause(group, visitedSegments)),
|
||||
);
|
||||
|
||||
if (condition.logic === 'AND') {
|
||||
return {AND: groupClauses};
|
||||
@@ -739,12 +835,23 @@ export class SegmentService {
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggered',
|
||||
'notTriggeredWithin',
|
||||
'memberOfSegment',
|
||||
'notMemberOfSegment',
|
||||
];
|
||||
|
||||
if (!validOperators.includes(filter.operator)) {
|
||||
throw new HttpException(400, `Invalid operator: ${filter.operator}`);
|
||||
}
|
||||
|
||||
// Segment membership operators use the segmentId encoded in the field name, no separate value needed
|
||||
if (filter.operator === 'memberOfSegment' || filter.operator === 'notMemberOfSegment') {
|
||||
if (!filter.field.startsWith('segment.')) {
|
||||
throw new HttpException(400, 'memberOfSegment/notMemberOfSegment operators require a segment field (segment.<id>)');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that operators that need a value have one
|
||||
const operatorsNeedingValue = [
|
||||
'equals',
|
||||
@@ -759,6 +866,7 @@ export class SegmentService {
|
||||
'olderThan',
|
||||
'triggeredWithin',
|
||||
'triggeredOlderThan',
|
||||
'notTriggeredWithin',
|
||||
];
|
||||
|
||||
if (operatorsNeedingValue.includes(filter.operator) && filter.value === undefined) {
|
||||
@@ -766,7 +874,7 @@ export class SegmentService {
|
||||
}
|
||||
|
||||
// Validate unit for time-based operators
|
||||
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan'].includes(filter.operator) && !filter.unit) {
|
||||
if (['within', 'triggeredWithin', 'olderThan', 'triggeredOlderThan', 'notTriggeredWithin'].includes(filter.operator) && !filter.unit) {
|
||||
throw new HttpException(400, `"${filter.operator}" operator requires a unit (days, hours, or minutes)`);
|
||||
}
|
||||
}
|
||||
@@ -774,27 +882,36 @@ export class SegmentService {
|
||||
/**
|
||||
* Build Prisma where clause from filter condition (entry point)
|
||||
*/
|
||||
private static buildWhereClause(projectId: string, condition: FilterCondition): Prisma.ContactWhereInput {
|
||||
private static async buildWhereClause(
|
||||
projectId: string,
|
||||
condition: FilterCondition,
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
return {
|
||||
projectId,
|
||||
...this.buildConditionClause(condition),
|
||||
...(await this.buildConditionClause(condition)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build Prisma clause from filter group (recursive)
|
||||
*/
|
||||
private static buildGroupClause(group: FilterGroup): Prisma.ContactWhereInput {
|
||||
private static async buildGroupClause(
|
||||
group: FilterGroup,
|
||||
visitedSegments = new Set<string>(),
|
||||
): Promise<Prisma.ContactWhereInput> {
|
||||
const clauses: Prisma.ContactWhereInput[] = [];
|
||||
|
||||
// Add filter conditions from this group
|
||||
if (group.filters.length > 0) {
|
||||
clauses.push(...group.filters.map(filter => this.buildFilterCondition(filter)));
|
||||
const filterClauses = await Promise.all(
|
||||
group.filters.map(filter => this.buildFilterCondition(filter, visitedSegments)),
|
||||
);
|
||||
clauses.push(...filterClauses);
|
||||
}
|
||||
|
||||
// Add nested condition if present
|
||||
if (group.conditions) {
|
||||
clauses.push(this.buildConditionClause(group.conditions));
|
||||
clauses.push(await this.buildConditionClause(group.conditions, visitedSegments));
|
||||
}
|
||||
|
||||
// All conditions within a group are combined with AND
|
||||
@@ -1147,6 +1264,28 @@ export class SegmentService {
|
||||
},
|
||||
};
|
||||
|
||||
case 'notTriggeredWithin': {
|
||||
// Contact has not triggered this event within the timeframe (includes never-triggered contacts)
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const since = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
events: {
|
||||
none: {
|
||||
name: eventName,
|
||||
createdAt: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for event field: ${operator}`);
|
||||
}
|
||||
@@ -1162,13 +1301,18 @@ export class SegmentService {
|
||||
value: unknown,
|
||||
unit?: 'days' | 'hours' | 'minutes',
|
||||
): Prisma.ContactWhereInput {
|
||||
// Map activity names to Email model fields
|
||||
// Map activity names to Email model fields (accept both verb and past-tense forms)
|
||||
const fieldMap: Record<string, string> = {
|
||||
open: 'openedAt',
|
||||
opened: 'openedAt',
|
||||
click: 'clickedAt',
|
||||
clicked: 'clickedAt',
|
||||
bounce: 'bouncedAt',
|
||||
bounced: 'bouncedAt',
|
||||
complaint: 'complainedAt',
|
||||
complained: 'complainedAt',
|
||||
sent: 'sentAt',
|
||||
delivery: 'deliveredAt',
|
||||
delivered: 'deliveredAt',
|
||||
};
|
||||
|
||||
@@ -1259,6 +1403,27 @@ export class SegmentService {
|
||||
},
|
||||
};
|
||||
|
||||
case 'notTriggeredWithin': {
|
||||
// Contact has not had this email activity within the timeframe (includes contacts with no activity)
|
||||
if (!unit) {
|
||||
throw new HttpException(400, 'Unit is required for "notTriggeredWithin" operator');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const milliseconds = this.getMilliseconds(value as number, unit);
|
||||
const since = new Date(now.getTime() - milliseconds);
|
||||
|
||||
return {
|
||||
emails: {
|
||||
none: {
|
||||
[field]: {
|
||||
gte: since,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new HttpException(400, `Unsupported operator for email activity field: ${operator}`);
|
||||
}
|
||||
|
||||
@@ -576,6 +576,66 @@ export class WorkflowService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Splice a step out of the flow: re-wire its parent(s) directly to its child,
|
||||
* then delete only the step itself. Not allowed for CONDITION or TRIGGER steps.
|
||||
*/
|
||||
public static async spliceStep(projectId: string, workflowId: string, stepId: string): Promise<void> {
|
||||
await this.get(projectId, workflowId);
|
||||
|
||||
const step = await prisma.workflowStep.findUnique({
|
||||
where: {id: stepId},
|
||||
include: {
|
||||
outgoingTransitions: true,
|
||||
incomingTransitions: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (step?.workflowId !== workflowId) {
|
||||
throw new HttpException(404, 'Workflow step not found');
|
||||
}
|
||||
|
||||
if (step.type === 'TRIGGER') {
|
||||
throw new HttpException(400, 'Cannot remove the trigger step.');
|
||||
}
|
||||
|
||||
if (step.type === 'CONDITION') {
|
||||
throw new HttpException(400, 'Cannot splice a condition step out of the flow.');
|
||||
}
|
||||
|
||||
// Check for active executions on this step
|
||||
const executionsOnStep = await prisma.workflowExecution.count({
|
||||
where: {
|
||||
workflowId,
|
||||
currentStepId: stepId,
|
||||
status: {in: [WorkflowExecutionStatus.RUNNING, WorkflowExecutionStatus.WAITING]},
|
||||
},
|
||||
});
|
||||
|
||||
if (executionsOnStep > 0) {
|
||||
throw new HttpException(
|
||||
409,
|
||||
`Cannot remove step "${step.name}" while ${executionsOnStep} execution(s) are currently on it. ` +
|
||||
'Please disable the workflow first or wait for executions to complete.',
|
||||
);
|
||||
}
|
||||
|
||||
// Re-wire: for each incoming transition, point it to our child (if we have one)
|
||||
const child = step.outgoingTransitions[0];
|
||||
|
||||
if (child) {
|
||||
for (const incoming of step.incomingTransitions) {
|
||||
await prisma.workflowTransition.update({
|
||||
where: {id: incoming.id},
|
||||
data: {toStepId: child.toStepId},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the step — cascades and removes its own transitions
|
||||
await prisma.workflowStep.delete({where: {id: stepId}});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a transition between two steps
|
||||
*/
|
||||
|
||||
@@ -336,19 +336,75 @@ describe('ContactService - Duplicate Prevention & Data Merging', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null values in data', async () => {
|
||||
it('should delete keys when null value is passed', async () => {
|
||||
const email = '[email protected]';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
middleName: null,
|
||||
middleName: null, // null should delete/not store the key
|
||||
lastName: 'Doe',
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('firstName', 'John');
|
||||
expect(contact.data).toHaveProperty('middleName', null);
|
||||
expect(contact.data).not.toHaveProperty('middleName'); // Key should not exist
|
||||
expect(contact.data).toHaveProperty('lastName', 'Doe');
|
||||
});
|
||||
|
||||
it('should remove existing fields when null is passed', async () => {
|
||||
const email = '[email protected]';
|
||||
|
||||
// Create contact with data
|
||||
await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
middleName: 'Michael',
|
||||
lastName: 'Doe',
|
||||
});
|
||||
|
||||
// Update with null to remove middleName
|
||||
const updated = await ContactService.upsert(projectId, email, {
|
||||
middleName: null, // Should delete this field
|
||||
});
|
||||
|
||||
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
|
||||
expect(updated.data).not.toHaveProperty('middleName'); // Removed
|
||||
expect(updated.data).toHaveProperty('lastName', 'Doe'); // Preserved
|
||||
});
|
||||
|
||||
it('should filter out empty string values from data', async () => {
|
||||
const email = '[email protected]';
|
||||
|
||||
const contact = await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
middleName: '', // Empty string should be filtered out
|
||||
lastName: 'Doe',
|
||||
company: '', // Empty string should be filtered out
|
||||
});
|
||||
|
||||
expect(contact.data).toHaveProperty('firstName', 'John');
|
||||
expect(contact.data).toHaveProperty('lastName', 'Doe');
|
||||
expect(contact.data).not.toHaveProperty('middleName');
|
||||
expect(contact.data).not.toHaveProperty('company');
|
||||
});
|
||||
|
||||
it('should not overwrite existing data with empty strings', async () => {
|
||||
const email = '[email protected]';
|
||||
|
||||
// Create contact with data
|
||||
await ContactService.upsert(projectId, email, {
|
||||
firstName: 'John',
|
||||
company: 'Acme Inc',
|
||||
});
|
||||
|
||||
// Update with empty string - should preserve existing values
|
||||
const updated = await ContactService.upsert(projectId, email, {
|
||||
firstName: '', // Should be filtered out, preserving "John"
|
||||
lastName: 'Doe',
|
||||
});
|
||||
|
||||
expect(updated.data).toHaveProperty('firstName', 'John'); // Preserved
|
||||
expect(updated.data).toHaveProperty('company', 'Acme Inc'); // Preserved
|
||||
expect(updated.data).toHaveProperty('lastName', 'Doe'); // New value
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contact CRUD Operations', () => {
|
||||
|
||||
@@ -787,8 +787,8 @@ describe('EmailService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should validate attachment size limit (10MB total)', () => {
|
||||
// Exceeds ~13.3M base64 chars limit
|
||||
it('should validate attachment size limit (10MB total default)', () => {
|
||||
// Exceeds ~13.4M base64 chars for the default 10MB limit
|
||||
const largeContent = 'A'.repeat(14000000);
|
||||
|
||||
const result = ActionSchemas.send.safeParse({
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="192" height="30" viewBox="0 0 192 30" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" fill="#C6FE1E"/>
|
||||
<rect x="0.3" y="0.3" width="29.4" height="29.4" rx="14.7" stroke="#B3E910" stroke-width="0.6"/>
|
||||
<mask id="mask0_1720_1740" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="5" y="8" width="20" height="15">
|
||||
<path d="M24.0038 8H5.99805V23H24.0038V8Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1720_1740)">
|
||||
<path d="M12.9679 11.4606H12.9599C12.4637 11.3182 11.9387 11.603 11.7707 12.0734C11.585 12.579 11.8795 13.1582 12.3997 13.3182C13.6737 13.6798 14.2211 11.8542 12.9679 11.4606Z" fill="#00160D"/>
|
||||
<path d="M23.8265 14.0425C22.1859 10.4505 16.9523 11.9369 16.4689 10.9961C15.0525 8.77368 12.4084 7.52568 9.54993 8.16888C9.10979 7.99288 7.59411 7.94488 6.6146 8.56088L7.20518 8.82168C7.25 8.84088 7.23719 8.83608 7.30281 8.86008C7.5701 8.96088 7.52368 8.93208 7.33162 9.03768C6.89148 9.29528 6.34251 9.59448 6 10.0441C6.0144 10.0649 6.75384 10.2425 6.75384 10.2425C6.76664 10.2457 6.90429 10.2601 6.87388 10.3209C4.45551 14.1385 8.5336 19.9001 10.5134 23.0009H16.2449C15.3598 21.4169 14.3483 19.2553 14.694 17.7881C14.7564 17.5225 14.8364 17.1849 15.1629 17.1401C15.952 17.0137 17.0083 17.0249 17.7605 16.9401C17.7605 16.9401 17.7643 16.9401 17.7717 16.9401C17.9318 16.9321 21.8498 16.4345 22.7877 18.8761C22.8678 19.1001 23.0518 18.9545 23.1254 18.8073C23.8249 17.4169 24.2762 15.2281 23.8297 14.0441L23.8265 14.0425ZM17.5013 12.8633C17.226 13.3545 17.0387 13.9929 16.9907 14.5497C16.9651 14.9033 17.0019 15.2521 17.0371 15.6057C17.0563 15.8009 17.0579 16.0473 16.9011 16.1753C16.765 16.2905 16.5393 16.3017 16.3121 16.3177C15.1965 16.3129 12.4757 16.3177 11.4273 15.6041L11.4209 15.5993C9.89564 14.6697 9.07938 12.5705 9.98527 10.9401C10.2782 10.3865 10.8175 10.0265 11.4321 9.89048C12.2228 9.70968 13.0935 9.84408 13.7993 10.2137C14.0874 10.3593 14.4315 10.5513 14.694 10.7721C15.2141 11.2265 15.6591 11.6985 16.3409 11.8441C16.6546 11.9337 16.9811 11.9209 17.2948 11.9849C17.8822 12.1257 17.7349 12.4601 17.4997 12.8601L17.5013 12.8633Z" fill="#00160D"/>
|
||||
</g>
|
||||
<path d="M37.392 23V7.4H43.296C47.688 7.4 50.952 10.568 50.952 15.176C50.952 19.64 47.904 23 43.632 23H37.392ZM40.056 20.48H43.44C46.176 20.48 48.168 18.272 48.168 15.2C48.168 12.08 46.08 9.848 43.2 9.848H40.056V20.48ZM57.774 23.288C54.246 23.288 51.75 20.672 51.75 17C51.75 13.328 54.246 10.712 57.774 10.712C61.302 10.712 63.822 13.328 63.822 17C63.822 20.672 61.302 23.288 57.774 23.288ZM57.774 20.816C59.766 20.816 61.206 19.232 61.206 17C61.206 14.72 59.766 13.112 57.774 13.112C55.782 13.112 54.366 14.72 54.366 17C54.366 19.232 55.782 20.816 57.774 20.816ZM69.9786 23.288C66.7866 23.288 64.5066 20.672 64.5066 17C64.5066 13.304 66.7626 10.712 69.9546 10.712C71.5866 10.712 73.0026 11.432 73.9626 12.656V6.68H76.4826V20.6L76.5306 23H74.4666L74.0826 21.2C73.1226 22.496 71.6826 23.288 69.9786 23.288ZM70.5546 20.816C72.5466 20.816 73.9866 19.208 73.9866 17C73.9866 14.744 72.5466 13.136 70.5546 13.136C68.5626 13.136 67.1226 14.744 67.1226 17C67.1226 19.208 68.5626 20.816 70.5546 20.816ZM83.8965 23.288C80.3685 23.288 77.8725 20.672 77.8725 17C77.8725 13.328 80.3685 10.712 83.8965 10.712C87.4245 10.712 89.9445 13.328 89.9445 17C89.9445 20.672 87.4245 23.288 83.8965 23.288ZM83.8965 20.816C85.8885 20.816 87.3285 19.232 87.3285 17C87.3285 14.72 85.8885 13.112 83.8965 13.112C81.9045 13.112 80.4885 14.72 80.4885 17C80.4885 19.232 81.9045 20.816 83.8965 20.816ZM95.667 23V7.4H102.603C105.507 7.4 107.571 9.488 107.571 12.416C107.571 15.368 105.435 17.48 102.483 17.48H98.331V23H95.667ZM98.331 15.2H102.315C103.755 15.2 104.787 14.096 104.787 12.536C104.787 10.976 103.755 9.848 102.315 9.848H98.331V15.2ZM111.711 23.288C109.359 23.288 107.775 21.872 107.775 19.784C107.775 18.176 109.023 16.832 110.919 16.472L114.711 15.728C115.071 15.68 115.311 15.416 115.311 15.104C115.311 13.88 114.423 12.992 113.175 12.992C111.879 12.992 110.871 13.904 110.775 15.272L108.183 14.648C108.567 12.344 110.583 10.712 113.055 10.712C115.839 10.712 117.831 12.584 117.831 15.176V20.6L117.879 23H115.887L115.455 21.368C114.711 22.544 113.343 23.288 111.711 23.288ZM110.487 19.448C110.487 20.312 111.183 20.888 112.335 20.888C114.015 20.888 115.311 19.664 115.311 17.864V17.312C115.071 17.36 114.807 17.432 114.543 17.48L111.927 17.96C111.039 18.128 110.487 18.704 110.487 19.448ZM119.784 25.64C120.84 25.568 121.728 24.992 122.16 23.984L122.712 22.616L117.984 11H120.864L123.96 19.688L126.792 11H129.624L124.44 24.728C123.696 26.696 122.376 27.944 120.744 28.088L119.784 25.64ZM130.303 23V11H132.295L132.727 12.944C133.279 11.624 134.647 10.712 136.255 10.712C137.887 10.712 139.327 11.648 139.951 13.016C140.551 11.648 142.015 10.712 143.695 10.712C146.167 10.712 147.943 12.728 147.943 15.536V23H145.423V15.872C145.423 14.336 144.439 13.208 143.071 13.208C141.535 13.208 140.431 14.504 140.431 16.28V23H137.959V15.92C137.959 14.336 136.951 13.208 135.559 13.208C133.975 13.208 132.823 14.528 132.823 16.376V23H130.303ZM155.115 23.288C151.587 23.288 149.115 20.672 149.115 17C149.115 13.328 151.539 10.712 154.947 10.712C158.763 10.712 160.971 13.976 160.635 17.792H151.803C152.115 19.616 153.411 20.84 155.139 20.84C156.507 20.84 157.611 20.072 157.899 18.944L160.467 19.856C159.843 21.896 157.707 23.288 155.115 23.288ZM151.827 15.92H157.995C157.755 14.24 156.507 13.112 154.947 13.112C153.363 13.112 152.163 14.24 151.827 15.92ZM162.027 23V11H164.043L164.427 12.968C165.075 11.624 166.587 10.712 168.315 10.712C170.955 10.712 172.827 12.8 172.827 15.656V23H170.307V16.064C170.307 14.384 169.203 13.184 167.595 13.184C165.843 13.184 164.547 14.552 164.547 16.472V23H162.027ZM180.599 23.024C178.103 23.792 175.175 23.024 175.175 19.976V13.328H173.375V11H174.071C174.815 11 175.295 10.448 175.295 9.608V7.808H177.695V11H180.455V13.328H177.695V19.4C177.695 20.792 179.015 21.176 180.311 20.696L180.599 23.024ZM186.24 23.288C183.288 23.288 181.08 21.68 180.888 19.448L183.768 18.776C183.84 20.072 184.944 21.032 186.408 21.032C187.536 21.032 188.376 20.432 188.376 19.568C188.376 17.096 181.464 18.032 181.464 14.072C181.464 12.08 183.312 10.712 185.928 10.712C188.616 10.712 190.56 12.104 190.752 14.048L188.016 14.672C187.992 13.616 187.104 12.896 185.856 12.896C184.8 12.896 184.08 13.424 184.08 14.192C184.08 16.592 191.088 15.608 191.088 19.664C191.088 21.776 189.072 23.288 186.24 23.288Z" fill="#00160D"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1,33 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Layer_1" x="0px" y="0px" width="100%" viewBox="0 0 2400 531" enable-background="new 0 0 2400 531" xml:space="preserve">
|
||||
|
||||
<path fill="#5A17DD" opacity="1.000000" stroke="none" d=" M177.531342,1.000000 C179.057083,2.016129 179.936691,3.463687 181.198273,3.982726 C196.529205,10.290123 206.246201,22.020750 211.758423,37.067310 C214.062714,43.357273 214.598770,50.496723 214.855927,57.290924 C215.321609,69.593918 214.975113,81.927650 214.610489,94.993073 C214.374802,105.590721 214.496323,115.443848 214.617844,125.296967 C214.731262,134.873642 214.844681,144.450302 214.696503,154.805176 C213.707352,157.641037 212.979782,159.698669 211.949341,161.867874 C185.808609,176.848602 159.744293,191.340149 134.183105,206.670563 C89.674866,233.364563 87.927750,297.038086 131.449005,324.829407 C157.666046,341.570770 185.279068,356.126007 212.344147,371.739380 C212.423538,371.815125 212.480209,372.027130 212.369049,372.418213 C212.169983,374.965424 212.006592,377.121552 212.005508,379.277710 C211.989853,410.547516 212.059357,441.817566 211.942383,473.086914 C211.919479,479.210052 212.244370,485.631836 210.591049,491.394318 C206.096237,507.060730 196.645874,519.126404 181.619370,526.280518 C177.450226,528.265381 173.208450,530.097656 169.000000,532.000000 C126.979103,532.000000 84.958214,532.000000 42.345772,531.676025 C41.124565,530.844910 40.572868,530.118103 39.853806,529.862976 C23.633379,524.108398 11.737153,513.795288 5.170585,497.604126 C4.555796,496.088257 2.426259,495.186737 1.000005,494.000000 C1.000000,341.645782 1.000000,189.291550 1.334632,36.704620 C2.153957,35.664326 2.605743,34.834736 3.128503,34.052578 C7.677545,27.246292 11.359032,19.558378 17.063332,13.917211 C22.027281,9.008199 29.292238,6.433895 35.493198,2.748482 C35.874008,2.522156 35.840652,1.598958 36.000000,1.000000 C83.020897,1.000000 130.041794,1.000000 177.531342,1.000000 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2401.000000,108.531357 C2398.361084,112.502449 2395.684814,115.977570 2393.088867,119.511719 C2376.352051,142.298141 2344.292480,145.556335 2323.355225,127.012016 C2308.465820,113.824196 2304.169189,97.286690 2308.368164,78.826729 C2311.976562,62.963165 2329.512207,45.781548 2347.566895,44.760101 C2359.529297,44.083317 2370.719727,44.744938 2380.889160,51.499744 C2388.212158,56.364056 2393.808350,62.843418 2397.873047,70.647614 C2398.621338,72.084328 2399.502930,73.451706 2400.661621,74.925537 C2401.000000,86.020897 2401.000000,97.041786 2401.000000,108.531357 z"/>
|
||||
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M169.449982,532.000000 C173.208450,530.097656 177.450226,528.265381 181.619370,526.280518 C196.645874,519.126404 206.096237,507.060730 210.591049,491.394318 C212.244370,485.631836 211.919479,479.210052 211.942383,473.086914 C212.059357,441.817566 211.989853,410.547516 212.005508,379.277710 C212.006592,377.121552 212.169983,374.965424 212.549744,372.721863 C214.349487,374.014984 215.857407,375.395538 217.365311,376.776123 C216.627380,376.596680 215.889450,376.417236 214.968765,376.193359 C214.968765,408.622589 215.150024,440.841736 214.894073,473.057465 C214.761459,489.751343 210.865372,505.497742 198.480194,517.622131 C194.147690,521.863403 188.375244,524.644470 183.216888,528.026001 C181.331070,529.262268 179.319336,530.306396 177.682526,531.719116 C175.299988,532.000000 172.599976,532.000000 169.449982,532.000000 z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.531342,1.000000 C35.840652,1.598958 35.874008,2.522156 35.493198,2.748482 C29.292238,6.433895 22.027281,9.008199 17.063332,13.917211 C11.359032,19.558378 7.677545,27.246292 3.128503,34.052578 C2.605743,34.834736 2.153957,35.664326 1.334632,36.235962 C1.000000,24.406746 1.000000,12.813491 1.000000,1.000000 C12.353279,1.000000 23.707981,1.000000 35.531342,1.000000 z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none" d=" M35.000000,532.000000 C23.740093,532.000000 12.480186,532.000000 1.000000,532.000000 C1.000000,520.980103 1.000000,509.958710 1.273971,498.144836 C3.225362,500.256165 4.904092,503.159210 6.579990,506.063904 C12.840562,516.914856 21.889536,524.612366 33.221706,529.786804 C33.990940,530.138062 34.415260,531.244690 35.000000,532.000000 z"/>
|
||||
<path fill="#6515E3" opacity="1.000000" stroke="none" d=" M35.437500,532.000000 C34.415260,531.244690 33.990940,530.138062 33.221706,529.786804 C21.889536,524.612366 12.840562,516.914856 6.579990,506.063904 C4.904092,503.159210 3.225362,500.256165 1.273971,497.676147 C1.000000,496.933319 1.000000,495.866669 1.000005,494.399994 C2.426259,495.186737 4.555796,496.088257 5.170585,497.604126 C11.737153,513.795288 23.633379,524.108398 39.853806,529.862976 C40.572868,530.118103 41.124565,530.844910 41.877113,531.676025 C39.958332,532.000000 37.916668,532.000000 35.437500,532.000000 z"/>
|
||||
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M217.701797,376.986237 C215.857407,375.395538 214.349487,374.014984 212.660889,372.330750 C212.480209,372.027130 212.423538,371.815125 212.456863,371.226196 C212.517105,301.832153 212.545151,233.027054 212.557709,164.221970 C212.557846,163.400101 212.358459,162.578186 212.252228,161.756302 C212.979782,159.698669 213.707352,157.641037 214.620636,155.265472 C215.344513,159.168213 217.859421,156.155777 218.749405,155.648453 C226.304214,151.342041 233.649414,146.669876 241.170090,142.301239 C255.990524,133.692307 270.865234,125.176292 285.751129,116.680748 C300.302887,108.375900 314.910339,100.168633 329.466583,91.871597 C338.091431,86.955467 346.691559,81.994705 355.254517,76.971642 C368.468292,69.220383 381.515259,61.175720 394.862762,53.663818 C410.999268,44.582294 426.814056,34.545036 443.863586,27.617456 C453.705078,23.618645 465.243103,23.051941 476.905975,25.483866 C488.326538,27.865265 498.757507,31.301670 507.881378,38.191151 C513.293030,42.277500 517.835571,47.514904 522.653503,52.659882 C522.633423,54.161774 522.340271,55.516911 522.882446,56.273506 C530.810730,67.338020 534.825439,79.932739 534.852600,93.238861 C535.088684,209.045868 535.109619,324.853851 534.812317,440.660553 C534.781067,452.832825 531.410339,464.720093 523.805542,474.833038 C520.241638,479.572327 516.591858,484.247070 512.647461,489.038055 C501.482788,497.758301 489.668152,504.786011 475.582489,505.381836 C465.246460,505.819031 454.537109,505.528076 444.535706,503.217438 C436.519775,501.365479 429.139435,496.303467 421.788544,492.118835 C400.427094,479.958344 379.239807,467.492371 357.951324,455.203186 C333.209625,440.920685 308.448273,426.672119 283.665619,412.460846 C270.255615,404.771027 256.805481,397.150024 243.301727,389.626770 C242.091843,388.952698 240.352737,389.228546 238.859421,389.063232 C238.616684,389.002136 238.373947,388.941010 237.917542,388.412506 C236.750076,387.639465 235.796280,387.333801 234.842499,387.028076 C229.241089,383.750854 223.639679,380.473602 217.701797,376.986237 M296.716461,310.239075 C304.943359,323.504425 315.864471,333.778931 329.811554,340.975372 C340.928680,346.711548 352.553436,350.545807 365.039307,350.861145 C373.992004,351.087280 382.719025,349.423523 391.553467,347.250214 C407.133850,343.417419 419.549103,334.649506 430.206177,323.768677 C438.862549,314.930573 445.481720,304.050262 449.011292,291.869385 C449.959442,291.079254 451.318390,290.469421 451.762421,289.457977 C452.481628,287.819763 452.588501,285.915344 452.974182,284.127716 C459.194275,255.296036 451.962433,229.585983 432.394684,208.282761 C406.602570,180.203156 367.770386,170.884338 331.654480,188.451065 C300.026855,203.834702 283.702362,229.894516 282.017212,265.013763 C281.409424,277.680542 284.214142,289.821014 289.155273,301.371735 C290.547760,304.626892 293.721344,307.120148 296.716461,310.239075 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1744.174194,276.125854 C1743.397339,250.037003 1716.894165,228.103333 1693.279297,227.846497 C1678.464111,227.685364 1665.452637,231.062683 1653.901001,240.373459 C1641.084229,250.704025 1634.178711,264.113495 1634.095581,280.405457 C1633.831787,332.062531 1634.002563,383.721802 1633.999146,435.380219 C1633.998535,444.009430 1633.983765,444.016296 1625.421631,444.019470 C1604.758423,444.027161 1584.093994,443.917572 1563.432617,444.113464 C1558.626099,444.159027 1556.942017,442.406403 1556.949707,437.716370 C1557.035278,385.225494 1556.844727,332.733521 1557.119385,280.243896 C1557.196655,265.465637 1559.156006,250.830490 1564.858765,236.920059 C1573.162476,216.665894 1585.719849,199.820221 1603.551514,186.918121 C1617.478394,176.841232 1632.717773,169.587479 1649.459961,166.024292 C1667.382202,162.209900 1685.510498,161.295441 1703.915283,162.719238 C1720.459717,163.999130 1736.135376,167.906235 1750.562622,175.621109 C1760.132935,180.738770 1768.686768,187.785461 1777.553345,194.166153 C1779.232666,195.374664 1780.243896,197.511505 1780.926636,198.395660 C1791.135864,191.360275 1800.034424,184.745819 1809.402466,178.881607 C1824.746704,169.276382 1841.720825,164.639603 1859.721313,162.872437 C1878.759521,161.003418 1897.559570,162.090714 1916.189331,165.925613 C1932.132935,169.207581 1946.731934,175.813446 1960.179443,185.075195 C1974.098267,194.661621 1985.271973,206.717941 1993.764893,221.264038 C2001.136719,233.890305 2005.305786,247.682434 2007.892090,261.974640 C2008.567871,265.708923 2008.931152,269.553040 2008.935791,273.347351 C2008.999023,325.839020 2008.968994,378.330811 2008.993652,430.822571 C2008.994629,432.803070 2009.134277,434.820038 2009.523438,436.756927 C2010.615234,442.191498 2009.403442,443.998505 2003.981934,444.011566 C1981.818970,444.064911 1959.654907,443.918945 1937.493530,444.109344 C1932.375488,444.153351 1931.930420,441.525269 1931.935669,437.517609 C1932.002563,386.192505 1931.280640,334.853943 1932.255737,283.547241 C1932.811768,254.288528 1910.071411,232.334351 1888.143066,228.585251 C1855.772339,223.050873 1827.595581,243.656631 1822.208252,272.253021 C1821.964111,273.548920 1822.057983,274.908508 1821.744141,277.005432 C1821.331299,279.082092 1821.027954,280.391724 1821.026855,281.701599 C1820.988525,331.474060 1820.969482,381.246521 1821.002930,431.018982 C1821.003906,432.622162 1821.579956,434.224945 1821.888062,435.827911 C1822.129761,443.746552 1821.848755,444.027374 1813.989990,444.025299 C1793.012451,444.019684 1772.034912,444.036743 1751.057373,444.017181 C1744.453125,444.011047 1744.104248,443.630005 1744.039062,436.847382 C1744.013428,434.184235 1744.070435,431.520264 1744.308838,428.041931 C1744.685913,425.915863 1744.981689,424.604614 1744.982422,423.293274 C1745.006958,375.850342 1745.012085,328.407440 1744.969727,280.964539 C1744.968384,279.351410 1744.451050,277.738739 1744.174194,276.125854 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M997.272705,222.229736 C981.579956,246.333069 966.124878,270.164185 950.641724,293.976990 C948.382446,297.451782 947.862183,300.378418 950.462341,304.391327 C965.829834,328.109100 980.822998,352.069336 995.963135,375.934570 C1008.594299,395.844666 1021.280762,415.719696 1033.861938,435.661285 C1035.171021,437.736084 1035.930542,440.157471 1036.944946,442.418121 C1034.288818,442.960144 1031.634888,443.960419 1028.976196,443.972900 C1003.812378,444.090881 978.647278,443.964325 953.483704,444.114227 C949.271606,444.139343 946.969055,442.605499 944.811951,438.979584 C933.002319,419.128113 920.879944,399.462677 908.860474,379.736115 C901.511658,367.675049 894.033752,355.690063 886.872803,343.518524 C884.639282,339.722260 882.293396,337.716003 877.502808,337.876617 C865.683594,338.272888 853.836426,338.240082 842.013184,337.916168 C837.057373,337.780365 835.857788,339.551331 835.884644,344.231659 C836.061646,375.061371 835.986694,405.892578 835.980469,436.723297 C835.979004,444.027771 835.955444,444.024841 828.814270,444.025848 C807.982849,444.028748 787.151489,444.039551 766.320068,444.035645 C759.405029,444.034332 759.030701,443.712067 759.028259,436.617920 C759.003723,365.124664 759.001770,293.631439 759.000061,222.138184 C758.998840,167.810226 759.013245,113.482269 759.004761,59.154316 C759.003479,51.066788 758.963196,51.056705 766.879456,51.054558 C787.877686,51.048862 808.877380,51.187645 829.872925,50.951572 C834.715637,50.897114 836.080383,52.406761 836.071045,57.188786 C835.936829,126.182701 835.980042,195.176971 835.979553,264.171143 C835.979492,271.954742 835.976196,271.973450 843.903870,271.988312 C854.902405,272.008881 865.901855,271.923706 876.899048,272.045898 C880.193970,272.082520 882.186279,271.132050 884.099304,268.093018 C900.273743,242.399155 916.729797,216.882706 933.067810,191.291580 C937.070435,185.022079 940.811890,178.580414 944.982178,172.427185 C946.032654,170.877335 948.267334,169.177643 949.969910,169.164948 C975.798828,168.972351 1001.629822,169.045746 1027.460327,169.085663 C1028.082520,169.086624 1028.704102,169.418045 1030.490356,169.927292 C1020.262573,188.101135 1008.265137,204.650833 997.272705,222.229736 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2204.980713,168.953308 C2221.043945,167.723450 2235.611572,171.417221 2248.408691,181.608231 C2262.422852,192.768524 2271.056885,206.990784 2273.989014,224.687134 C2275.840088,235.861588 2275.574219,246.926849 2271.864502,257.679626 C2266.530029,273.142853 2256.308350,284.991730 2243.103271,294.200104 C2227.181641,305.303131 2210.964111,315.983337 2194.834961,326.786987 C2176.219727,339.255676 2157.545654,351.635986 2138.938721,364.116943 C2136.184814,365.964355 2133.499512,367.985107 2131.103760,370.263489 C2130.050293,371.265289 2128.992188,373.410431 2129.392334,374.503479 C2129.902588,375.897949 2131.739502,377.129395 2133.278320,377.731842 C2134.744141,378.305695 2136.562012,378.041351 2138.225098,378.041504 C2163.891357,378.043518 2189.561035,377.755280 2215.222656,378.099121 C2238.070801,378.405243 2258.320312,385.447876 2273.743652,403.282074 C2282.235352,413.101044 2287.425049,424.725464 2288.739502,437.407593 C2290.328369,452.744629 2290.283447,468.249268 2291.014404,483.678101 C2291.190674,487.394714 2289.923584,489.159607 2285.892822,489.130707 C2267.590576,488.999359 2249.286865,489.078583 2230.983643,489.046204 C2224.881592,489.035400 2224.096680,488.253265 2224.054443,482.091431 C2223.998291,473.925415 2224.121826,465.757599 2224.010254,457.592712 C2223.896973,449.294525 2218.450439,444.013275 2210.188721,444.013000 C2177.356201,444.011871 2144.513184,444.532867 2111.694092,443.876923 C2084.124512,443.325928 2064.515137,429.566284 2052.293457,404.840302 C2045.920166,391.946564 2044.336182,378.257721 2046.364868,364.535309 C2048.635010,349.178345 2056.038330,335.666229 2068.413086,326.050751 C2082.422852,315.164703 2097.209473,305.262756 2111.828857,295.180389 C2134.516357,279.533936 2157.348389,264.096436 2180.119873,248.571442 C2182.999268,246.608231 2185.907227,244.685928 2188.759277,242.683517 C2192.981934,239.718597 2193.051514,238.521896 2189.006348,233.956650 C2186.638428,232.871384 2184.622803,232.059738 2182.605957,232.056213 C2141.426758,231.984299 2100.247803,232.004745 2059.068604,232.006760 C2059.047607,212.932755 2059.107666,193.858063 2058.941895,174.785309 C2058.905029,170.534119 2060.306885,168.788834 2064.690674,168.936020 C2071.814453,169.175186 2078.952881,168.985855 2087.021729,168.979980 C2126.965820,168.972794 2165.973389,168.963058 2204.980713,168.953308 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1324.748779,324.688202 C1325.002808,341.574371 1330.428589,356.530090 1342.807983,368.154205 C1360.779785,385.029358 1382.398315,389.132507 1404.756104,380.121887 C1424.917969,371.996246 1438.287231,356.643585 1440.149292,333.570282 C1440.200928,332.929443 1440.546387,332.312317 1441.273193,331.148987 C1442.192749,328.741425 1442.941895,326.869507 1442.944336,324.996613 C1443.008179,275.239044 1443.006592,225.481339 1442.951050,175.723755 C1442.948853,173.800507 1442.302612,171.877975 1441.956543,169.955124 C1443.713989,169.636307 1445.470459,169.045197 1447.228882,169.040070 C1468.894775,168.976868 1490.562500,169.132477 1512.226074,168.895340 C1516.973755,168.843384 1518.081055,170.485931 1518.072388,174.928711 C1517.967285,229.093658 1518.430786,283.263855 1517.764526,337.420654 C1517.596558,351.070282 1513.692871,364.537415 1507.974609,377.318054 C1497.651611,400.390869 1481.522705,418.155945 1460.118652,431.234528 C1445.469849,440.185425 1429.464355,445.989227 1412.473145,447.821259 C1397.478638,449.437988 1382.223877,450.636871 1367.222656,449.728302 C1344.637451,448.360382 1322.903198,442.137604 1303.746826,429.729980 C1278.831665,413.592529 1261.509277,391.529053 1253.387329,362.369171 C1249.724243,349.217041 1248.143799,336.007904 1248.088867,322.576202 C1247.889404,273.744965 1247.997314,224.912491 1247.979248,176.080475 C1247.976685,169.203812 1248.125977,169.004700 1254.911133,169.001877 C1275.577271,168.993271 1296.243408,168.999054 1316.909546,169.004456 C1325.084473,169.006592 1325.084473,169.012848 1324.666870,178.200836 C1324.302979,188.990707 1324.174561,198.985367 1324.046143,208.980026 C1324.038940,237.012619 1324.031982,265.045227 1324.020264,294.014618 C1324.010010,297.109589 1323.947144,299.269409 1324.009399,301.425629 C1324.232910,309.180481 1324.498901,316.934113 1324.748779,324.688202 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M1199.972778,168.956024 C1203.770874,168.731750 1207.571411,168.541504 1211.366333,168.272598 C1214.481812,168.051849 1216.882690,169.153198 1216.878052,172.471832 C1216.850830,192.436768 1216.646729,212.401474 1215.937500,232.479248 C1214.740112,232.415009 1214.107422,232.084122 1213.473877,232.082840 C1192.925293,232.041290 1172.376465,231.980743 1151.829224,232.138947 C1150.552002,232.148788 1149.288086,233.913574 1148.018066,234.861877 C1140.789795,237.727463 1137.886230,242.948029 1137.907104,250.794800 C1138.072021,312.592224 1138.000488,374.390259 1137.996826,436.188110 C1137.996338,444.058746 1137.983398,444.035858 1130.363525,444.031433 C1109.375732,444.019287 1088.385986,443.880737 1067.401245,444.133820 C1062.243896,444.196014 1060.914062,442.400757 1060.923218,437.485229 C1061.046265,371.523376 1060.580811,305.557281 1061.192627,239.600922 C1061.443604,212.545868 1073.475708,190.881134 1097.595825,177.444977 C1107.154053,172.120529 1117.863037,169.110855 1129.092651,169.056427 C1133.423340,169.035431 1137.753906,169.004639 1143.021240,168.980804 C1162.629395,168.974197 1181.301147,168.965103 1199.972778,168.956024 z"/>
|
||||
<path fill="#211438" opacity="1.000000" stroke="none" d=" M2380.845947,169.001526 C2381.656006,169.001328 2381.989258,169.004852 2382.322510,169.000549 C2393.011719,168.862213 2393.027100,168.862091 2393.027344,180.050995 C2393.030029,265.520142 2393.027588,350.989288 2393.020996,436.458435 C2393.020508,444.034637 2393.002197,444.032440 2385.673828,444.031891 C2364.681641,444.030304 2343.689697,444.034119 2322.697510,444.028229 C2316.246338,444.026428 2316.014404,443.808044 2316.014404,437.219147 C2316.015137,349.750580 2316.058105,262.281952 2315.937256,174.813583 C2315.930664,170.164124 2317.346436,168.858414 2321.891113,168.908417 C2341.381592,169.122910 2360.876221,169.000931 2380.845947,169.001526 z"/>
|
||||
<path fill="#F69B04" opacity="1.000000" stroke="none" d=" M512.981018,488.951050 C516.591858,484.247070 520.241638,479.572327 523.805542,474.833038 C531.410339,464.720093 534.781067,452.832825 534.812317,440.660553 C535.109619,324.853851 535.088684,209.045868 534.852600,93.238861 C534.825439,79.932739 530.810730,67.338020 522.882446,56.273506 C522.340271,55.516911 522.633423,54.161774 522.811768,52.966721 C532.746765,66.097198 537.132141,80.735252 537.097900,97.310219 C536.859070,213.109009 537.071228,328.908752 536.783203,444.707336 C536.766357,451.480072 534.288208,458.484283 531.837341,464.957458 C528.213989,474.527679 522.099121,482.680542 514.226868,489.753632 C513.494446,489.452698 513.237732,489.201874 512.981018,488.951050 z"/>
|
||||
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M512.647461,489.038055 C513.237732,489.201874 513.494446,489.452698 513.919800,489.912903 C506.566620,496.667297 498.159180,501.780334 488.607025,504.629608 C483.231537,506.233032 477.625824,507.324402 472.046051,507.931946 C458.426300,509.414917 445.249451,507.433197 433.044342,500.998230 C424.097961,496.281494 415.389160,491.111206 406.608948,486.082886 C393.976715,478.848572 381.364380,471.579254 368.764679,464.288452 C356.062378,456.938293 343.425201,449.474762 330.683655,442.193573 C322.712036,437.638092 314.510223,433.482849 306.571594,428.872711 C293.298065,421.164520 280.196411,413.160675 266.937836,405.426239 C257.755310,400.069580 248.430557,394.956635 239.014374,389.399048 C240.352737,389.228546 242.091843,388.952698 243.301727,389.626770 C256.805481,397.150024 270.255615,404.771027 283.665619,412.460846 C308.448273,426.672119 333.209625,440.920685 357.951324,455.203186 C379.239807,467.492371 400.427094,479.958344 421.788544,492.118835 C429.139435,496.303467 436.519775,501.365479 444.535706,503.217438 C454.537109,505.528076 465.246460,505.819031 475.582489,505.381836 C489.668152,504.786011 501.482788,497.758301 512.647461,489.038055 z"/>
|
||||
<path fill="#281F4A" opacity="1.000000" stroke="none" d=" M1441.719482,170.298798 C1442.302612,171.877975 1442.948853,173.800507 1442.951050,175.723755 C1443.006592,225.481339 1443.008179,275.239044 1442.944336,324.996613 C1442.941895,326.869507 1442.192749,328.741425 1441.453369,330.780579 C1441.090210,278.826324 1441.062012,226.705276 1441.058960,174.584229 C1441.058838,173.270309 1441.334961,171.956390 1441.719482,170.298798 z"/>
|
||||
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M2059.285156,232.323334 C2100.247803,232.004745 2141.426758,231.984299 2182.605957,232.056213 C2184.622803,232.059738 2186.638428,232.871384 2188.822266,233.657455 C2148.397949,234.004074 2107.805420,234.024551 2067.213379,233.937744 C2064.641846,233.932251 2062.072266,233.091873 2059.285156,232.323334 z"/>
|
||||
<path fill="#430043" opacity="1.000000" stroke="none" d=" M1821.915771,435.376678 C1821.579956,434.224945 1821.003906,432.622162 1821.002930,431.018982 C1820.969482,381.246521 1820.988525,331.474060 1821.026855,281.701599 C1821.027954,280.391724 1821.331299,279.082092 1821.718506,277.440002 C1821.943604,329.713562 1821.943481,382.319489 1821.915771,435.376678 z"/>
|
||||
<path fill="#2E002E" opacity="1.000000" stroke="none" d=" M1744.130127,276.579529 C1744.451050,277.738739 1744.968384,279.351410 1744.969727,280.964539 C1745.012085,328.407440 1745.006958,375.850342 1744.982422,423.293274 C1744.981689,424.604614 1744.685913,425.915863 1744.323242,427.571960 C1744.107910,377.622253 1744.097046,327.327728 1744.130127,276.579529 z"/>
|
||||
<path fill="#2F194A" opacity="1.000000" stroke="none" d=" M1148.413940,234.743088 C1149.288086,233.913574 1150.552002,232.148788 1151.829224,232.138947 C1172.376465,231.980743 1192.925293,232.041290 1213.473877,232.082840 C1214.107422,232.084122 1214.740112,232.415009 1215.717163,232.834854 C1214.148804,233.389725 1212.237549,233.966492 1210.324219,233.973190 C1191.774780,234.038177 1173.224854,233.989151 1154.675293,234.031494 C1152.719727,234.035950 1150.764893,234.417633 1148.413940,234.743088 z"/>
|
||||
<path fill="#000080" opacity="1.000000" stroke="none" d=" M2204.687988,168.654999 C2165.973389,168.963058 2126.965820,168.972794 2087.491943,168.968307 C2088.258301,168.637283 2089.490723,168.045258 2090.723877,168.043686 C2127.796387,167.996536 2164.868896,168.005508 2201.941406,168.024231 C2202.759277,168.024643 2203.577393,168.241028 2204.687988,168.654999 z"/>
|
||||
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1199.681885,168.656769 C1181.301147,168.965103 1162.629395,168.974197 1143.491089,168.969086 C1143.591187,168.664795 1144.158691,168.120102 1144.724731,168.121521 C1162.946899,168.167679 1181.168945,168.266556 1199.681885,168.656769 z"/>
|
||||
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.344482,208.687668 C1324.174561,198.985367 1324.302979,188.990707 1324.605469,178.658905 C1324.734009,188.346268 1324.688477,198.370789 1324.344482,208.687668 z"/>
|
||||
<path fill="#000080" opacity="1.000000" stroke="none" d=" M1324.820923,324.249207 C1324.498901,316.934113 1324.232910,309.180481 1324.009399,301.425629 C1323.947144,299.269409 1324.010010,297.109589 1324.030762,294.484253 C1324.363892,297.208466 1324.900757,300.396149 1324.954712,303.591980 C1325.068115,310.329132 1324.928711,317.070526 1324.820923,324.249207 z"/>
|
||||
<path fill="#FF00FF" opacity="1.000000" stroke="none" d=" M214.788666,124.825363 C214.496323,115.443848 214.374802,105.590721 214.562286,95.472626 C214.900681,104.923019 214.930084,114.638390 214.788666,124.825363 z"/>
|
||||
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M234.931915,387.307251 C235.796280,387.333801 236.750076,387.639465 237.613724,388.191864 C236.689499,388.154510 235.855423,387.870422 234.931915,387.307251 z"/>
|
||||
<path fill="#221538" opacity="1.000000" stroke="none" d=" M211.949326,161.867874 C212.358459,162.578186 212.557846,163.400101 212.557709,164.221970 C212.545151,233.027054 212.517105,301.832153 212.377472,371.150421 C185.279068,356.126007 157.666046,341.570770 131.449005,324.829407 C87.927750,297.038086 89.674866,233.364563 134.183105,206.670563 C159.744293,191.340149 185.808609,176.848602 211.949326,161.867874 z"/>
|
||||
<path fill="#000000" opacity="1.000000" stroke="none" d=" M448.520416,291.576965 C445.481720,304.050262 438.862549,314.930573 430.206177,323.768677 C419.549103,334.649506 407.133850,343.417419 391.553467,347.250214 C382.719025,349.423523 373.992004,351.087280 365.039307,350.861145 C352.553436,350.545807 340.928680,346.711548 329.811554,340.975372 C315.864471,333.778931 304.943359,323.504425 296.341583,309.739136 C293.122955,302.430634 289.375671,295.847046 287.613251,288.769196 C283.192322,271.015137 283.284637,253.339325 290.253174,235.913177 C294.830750,224.466003 301.326721,214.610947 310.311005,206.244354 C329.706665,188.182205 352.128204,179.648621 378.927307,182.865051 C395.792664,184.889221 410.374329,191.639160 422.842163,202.571182 C436.770416,214.783829 446.404449,229.586563 450.512085,248.381592 C453.334473,261.295746 452.945831,273.469971 450.115540,286.313507 C450.094208,287.099060 450.145050,287.549835 450.121063,288.296448 C449.537628,289.587189 449.029022,290.582062 448.520416,291.576965 z"/>
|
||||
<path fill="#FEA802" opacity="1.000000" stroke="none" d=" M448.765869,291.723175 C449.029022,290.582062 449.537628,289.587189 450.257416,288.114380 C450.374969,287.083923 450.281342,286.531342 450.187683,285.978760 C452.945831,273.469971 453.334473,261.295746 450.512085,248.381592 C446.404449,229.586563 436.770416,214.783829 422.842163,202.571182 C410.374329,191.639160 395.792664,184.889221 378.927307,182.865051 C352.128204,179.648621 329.706665,188.182205 310.311005,206.244354 C301.326721,214.610947 294.830750,224.466003 290.253174,235.913177 C283.284637,253.339325 283.192322,271.015137 287.613251,288.769196 C289.375671,295.847046 293.122955,302.430634 296.022339,309.601044 C293.721344,307.120148 290.547760,304.626892 289.155273,301.371735 C284.214142,289.821014 281.409424,277.680542 282.017212,265.013763 C283.702362,229.894516 300.026855,203.834702 331.654480,188.451065 C367.770386,170.884338 406.602570,180.203156 432.394684,208.282761 C451.962433,229.585983 459.194275,255.296036 452.974182,284.127716 C452.588501,285.915344 452.481628,287.819763 451.762421,289.457977 C451.318390,290.469421 449.959442,291.079254 448.765869,291.723175 z"/>
|
||||
<path fill="#F98F03" opacity="1.000000" stroke="none" d=" M450.115540,286.313507 C450.281342,286.531342 450.374969,287.083923 450.332275,287.818573 C450.145050,287.549835 450.094208,287.099060 450.115540,286.313507 z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 22 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="840" height="262">
|
||||
<path d="M0 0 C8.69163006 7.71008755 13.14490087 16.89394432 14.22265625 28.37109375 C14.64705043 39.90354435 11.2436751 50.02540655 4 59 C-2.73007826 65.71611544 -11.11233981 69.18417403 -20.5625 69.4375 C-29.02193816 69.14854393 -34.47069789 67.31848169 -41 62 C-41.02505615 63.02472412 -41.0501123 64.04944824 -41.07592773 65.10522461 C-41.16984484 68.89409676 -41.27010317 72.68277881 -41.37231445 76.47143555 C-41.41572297 78.11364303 -41.45739358 79.75589739 -41.49731445 81.39819336 C-41.5548744 83.75388745 -41.61860892 86.10934428 -41.68359375 88.46484375 C-41.70866249 89.57266457 -41.70866249 89.57266457 -41.73423767 90.7028656 C-41.88612829 95.88612829 -41.88612829 95.88612829 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.34 -3.35 -41.68 -1.7 -41 0 C-40.5153125 -0.33773437 -40.030625 -0.67546875 -39.53125 -1.0234375 C-26.76996544 -9.51459992 -12.64739523 -8.64871948 0 0 Z M-39.875 17.0625 C-43.59522645 22.95180763 -44.64765142 30.01356041 -43.375 36.8125 C-41.94960016 41.95313555 -39.20005286 46.63995771 -35 50 C-28.58262778 53.05106644 -21.3538322 53.05583606 -14.625 50.9375 C-9.59434454 47.22439716 -5.93848019 43.00217489 -4 37 C-3.12848553 29.65293034 -3.43441364 23.03609731 -8 17 C-11.50353653 13.33721181 -15.41705741 10.34492712 -20.515625 9.6640625 C-28.47529099 9.55035299 -34.61737158 10.70443772 -39.875 17.0625 Z " fill="#070709" transform="translate(709,111)"/>
|
||||
<path d="M0 0 C8.67817735 7.43616297 12.59651032 17.33058774 13.83984375 28.484375 C14.34206719 39.50879197 11.02283105 49.29470477 4.40234375 58.1328125 C-1.24768253 64.30376319 -9.32101987 68.57263825 -17.69921875 69.203125 C-27.77294256 69.3611442 -33.59239913 67.60506725 -42 62 C-42.33 73.55 -42.66 85.1 -43 97 C-48.61 97 -54.22 97 -60 97 C-60 63.34 -60 29.68 -60 -5 C-54.39 -5 -48.78 -5 -43 -5 C-42.67 -3.02 -42.34 -1.04 -42 1 C-41.443125 0.59007813 -40.88625 0.18015625 -40.3125 -0.2421875 C-27.3317457 -9.40765253 -13.36734014 -9.00970502 0 0 Z M-39.875 17.3125 C-44.23157366 24.27418696 -44.76257396 31.08547435 -43 39 C-40.78638 44.12807721 -37.69263635 47.91273924 -33 51 C-27.15219658 52.94926781 -20.47788508 52.78007493 -14.625 50.9375 C-9.34602898 47.04111663 -5.31463083 42.57315414 -4 36 C-3.21564863 28.35613939 -4.61809235 21.89395052 -9.25 15.6875 C-13.66336941 11.37443444 -17.85242164 9.85182121 -23.9375 9.5625 C-30.64560394 9.80717248 -35.72726168 11.94341599 -39.875 17.3125 Z " fill="#08080A" transform="translate(792,111)"/>
|
||||
<path d="M0 0 C0.33 -1.65 0.66 -3.3 1 -5 C6.61 -5 12.22 -5 18 -5 C18 18.76 18 42.52 18 67 C12.39 67 6.78 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-0.76183594 62.50660156 -0.76183594 62.50660156 -1.5390625 63.0234375 C-2.22742188 63.46945313 -2.91578125 63.91546875 -3.625 64.375 C-4.29789062 64.81585938 -4.97078125 65.25671875 -5.6640625 65.7109375 C-13.58368891 70.0812999 -23.10758184 70.39808303 -31.75 67.9375 C-41.62995371 64.03990358 -47.55506909 56.75034748 -52.25390625 47.54296875 C-56.35291308 38.0167055 -56.62716497 26.83848226 -53.375 17 C-49.4819062 7.90703965 -42.53172578 -0.6908008 -33.375 -4.875 C-21.19078419 -8.93640527 -10.59035975 -6.93997523 0 0 Z M-34 18 C-37.50770356 23.5121056 -38.64816392 29.16310107 -37.5625 35.58984375 C-35.7557313 42.41655504 -33.20468193 46.65511078 -27.4375 50.875 C-20.99668564 52.98290288 -13.87867289 53.19665826 -7.5625 50.5 C-2.42266422 47.51247045 0.16916612 42.49250164 2 37 C2.63422426 29.45029199 2.42141667 22.41441403 -2.359375 16.24609375 C-5.96089837 12.3939489 -9.32538157 10.29977528 -14.68359375 9.69921875 C-23.26189343 9.66271535 -28.25050093 11.31814973 -34 18 Z " fill="#070709" transform="translate(467,111)"/>
|
||||
<path d="M0 0 C0.66 -1.65 1.32 -3.3 2 -5 C7.28 -5 12.56 -5 18 -5 C18.95704986 -1.17180055 19.12078914 1.77386172 19.11352539 5.69628906 C19.11367142 6.36011597 19.11381744 7.02394287 19.1139679 7.70788574 C19.11327332 9.8938325 19.10550231 12.07969319 19.09765625 14.265625 C19.09579045 15.78448439 19.09436731 17.30334438 19.09336853 18.82220459 C19.0895561 22.81377839 19.0797367 26.80531562 19.06866455 30.796875 C19.05842303 34.8723908 19.05386504 38.94791253 19.04882812 43.0234375 C19.03811327 51.01564362 19.0205268 59.00781305 19 67 C13.06 67 7.12 67 1 67 C0.67 65.35 0.34 63.7 0 62 C-1.010625 62.78375 -2.02125 63.5675 -3.0625 64.375 C-10.10252051 69.32545267 -17.61014034 69.82953556 -26 69 C-34.99458455 66.97833698 -42.57884408 62.69673031 -48.04296875 55.1953125 C-54.42675603 44.84962837 -56.76481094 34.43070846 -54.5 22.328125 C-51.38068518 10.91426851 -45.31231302 1.98738781 -35.0078125 -4.1953125 C-22.99567478 -9.69420221 -10.51231494 -7.00820996 0 0 Z M-33.703125 18.0859375 C-37.69553255 23.97834623 -37.83104269 30.1032024 -37 37 C-35.40099464 42.03034735 -33.28906668 46.48368849 -28.875 49.5625 C-22.1750793 52.91246035 -15.37047301 52.88405849 -8.1875 51 C-3.21333977 47.87895829 0.14016889 43.57949332 2 38 C2.61486532 30.06722942 3.08216386 22.53421067 -2 16 C-6.55661235 11.56022386 -11.09995043 9.7144683 -17.4375 9.6875 C-25.03557191 9.87083149 -28.89903754 12.42951194 -33.703125 18.0859375 Z " fill="#080809" transform="translate(617,111)"/>
|
||||
<path d="M0 0 C6.27 0 12.54 0 19 0 C19.86882812 2.09859375 20.73765625 4.1971875 21.6328125 6.359375 C22.49571507 8.43516367 23.36009471 10.51033151 24.22460938 12.58544922 C24.81566696 14.00635935 25.40523461 15.42789035 25.99316406 16.85009766 C29.67484115 25.75271955 33.54281316 34.45146929 38 43 C38.33209473 42.2251123 38.66418945 41.45022461 39.00634766 40.65185547 C42.10668945 33.41772461 42.10668945 33.41772461 45.20703125 26.18359375 C45.46780457 25.57501526 45.72857788 24.96643677 45.99725342 24.3394165 C46.52595131 23.10600543 47.05496437 21.8727294 47.58428955 20.6395874 C48.96437415 17.42332337 50.33821498 14.20459532 51.70214844 10.98144531 C51.97634613 10.33563507 52.25054382 9.68982483 52.53305054 9.02444458 C53.29495211 7.22937564 54.05452493 5.43331881 54.81396484 3.63720703 C56 1 56 1 57 0 C58.34267413 -0.08631874 59.68974322 -0.10706473 61.03515625 -0.09765625 C61.84404297 -0.09443359 62.65292969 -0.09121094 63.48632812 -0.08789062 C64.33646484 -0.07951172 65.18660156 -0.07113281 66.0625 -0.0625 C66.91650391 -0.05798828 67.77050781 -0.05347656 68.65039062 -0.04882812 C70.76695663 -0.03700373 72.88348685 -0.01906769 75 0 C73.16191407 5.91473461 70.9830678 11.44726207 68.25 17 C63.77345085 26.26927146 59.49910534 35.62503109 55.25 45 C54.92825806 45.70946777 54.60651611 46.41893555 54.27502441 47.14990234 C52.74027556 50.53437081 51.20654663 53.9192991 49.67382812 57.3046875 C47.45465601 62.20559819 45.22751178 67.10287487 43 72 C39.37 72 35.74 72 32 72 C26.23725499 60.94458283 21.08034555 49.63843719 16.02001953 38.24780273 C15.46135932 36.99067986 14.90260272 35.73359981 14.34375 34.4765625 C14.06707489 33.85417191 13.79039978 33.23178131 13.50534058 32.5905304 C11.61205991 28.34301709 9.67729179 24.11638141 7.71875 19.8984375 C7.20739502 18.79628906 6.69604004 17.69414063 6.16918945 16.55859375 C5.20099592 14.47830973 4.2283629 12.40008459 3.25073242 10.32421875 C2.60648315 8.93783203 2.60648315 8.93783203 1.94921875 7.5234375 C1.57112061 6.71825684 1.19302246 5.91307617 0.8034668 5.08349609 C0 3 0 3 0 0 Z " fill="#080809" transform="translate(250,106)"/>
|
||||
<path d="M0 0 C5.61 0 11.22 0 17 0 C17 35.64 17 71.28 17 108 C11.39 108 5.78 108 0 108 C0 72.36 0 36.72 0 0 Z " fill="#08080A" transform="translate(499,70)"/>
|
||||
<path d="M0 0 C1.58203125 1.66796875 1.58203125 1.66796875 1.58203125 5.66796875 C-0.24609375 8.0625 -0.24609375 8.0625 -2.66796875 10.48046875 C-3.456875 11.29128906 -4.24578125 12.10210937 -5.05859375 12.9375 C-7.41796875 14.66796875 -7.41796875 14.66796875 -9.58984375 14.4921875 C-10.87844933 13.91121361 -12.15367922 13.30011351 -13.41796875 12.66796875 C-18.59558143 12.15189951 -23.07162336 12.11500546 -27.91796875 14.10546875 C-33.66913025 20.00040929 -33.87356406 28.09995626 -33.9296875 35.953125 C-33.9476387 36.8948204 -33.9655899 37.83651581 -33.98408508 38.80674744 C-34.03777974 41.78127717 -34.07195171 44.75565028 -34.10546875 47.73046875 C-34.13867794 49.76108932 -34.17317088 51.79168932 -34.20898438 53.82226562 C-34.29348231 58.77071953 -34.36048689 63.71913029 -34.41796875 68.66796875 C-40.02796875 68.66796875 -45.63796875 68.66796875 -51.41796875 68.66796875 C-51.41796875 44.90796875 -51.41796875 21.14796875 -51.41796875 -3.33203125 C-45.80796875 -3.33203125 -40.19796875 -3.33203125 -34.41796875 -3.33203125 C-34.08796875 -1.68203125 -33.75796875 -0.03203125 -33.41796875 1.66796875 C-32.67546875 1.04921875 -31.93296875 0.43046875 -31.16796875 -0.20703125 C-21.53059322 -7.65409416 -9.77403562 -6.65866875 0 0 Z " fill="#070709" transform="translate(412.41796875,109.33203125)"/>
|
||||
<path d="M0 0 C3.62365347 3.99851417 3.44730059 8.25868181 3.28125 13.40625 C2.92171053 16.72200287 2.29410204 18.55295783 0 21 C-4.52162441 23.74080002 -7.47400283 24.31760393 -12.7109375 23.8359375 C-13.3530014 23.77965179 -13.99506531 23.72336609 -14.65658569 23.66537476 C-16.77451134 23.46887205 -18.8865471 23.23974308 -21 23 C-22.10486755 22.87558533 -22.10486755 22.87558533 -23.23205566 22.74865723 C-27.90893854 22.20777275 -32.5744168 21.59966694 -37.23779297 20.953125 C-39.65576921 20.62077073 -42.07569435 20.30634824 -44.49609375 19.9921875 C-46.05223146 19.77972796 -47.60822721 19.56622547 -49.1640625 19.3515625 C-50.23595322 19.21544052 -50.23595322 19.21544052 -51.32949829 19.0765686 C-55.51250758 18.4749119 -58.59393869 17.53147784 -62 15 C-63.43735246 12.12529507 -63.38172649 10.18105412 -63 7 C-58.54839678 1.78836696 -52.21129528 1.72201371 -45.8125 0.875 C-44.07432007 0.6279834 -44.07432007 0.6279834 -42.30102539 0.37597656 C-38.8697716 -0.10371979 -35.43624001 -0.55757005 -32 -1 C-31.06575195 -1.12149414 -30.13150391 -1.24298828 -29.16894531 -1.36816406 C-26.75978753 -1.6751507 -24.34975685 -1.96848703 -21.9375 -2.25 C-21.25598877 -2.33008301 -20.57447754 -2.41016602 -19.87231445 -2.49267578 C-4.80907763 -4.10186033 -4.80907763 -4.10186033 0 0 Z " fill="#FE7039" transform="translate(199,120)"/>
|
||||
<path d="M0 0 C0.85850555 0.1038652 1.71701111 0.20773041 2.60153198 0.31474304 C4.40186289 0.53467384 6.20184063 0.75751383 8.00146484 0.9831543 C10.74057943 1.32401864 13.48138243 1.64772427 16.22265625 1.97070312 C17.98189995 2.18775813 19.74101944 2.4058228 21.5 2.625 C22.31058868 2.71888504 23.12117737 2.81277008 23.95632935 2.90950012 C28.99308043 3.56175949 33.10027078 4.64497567 37.6171875 7.01171875 C39.6171875 10.01171875 39.6171875 10.01171875 39.3671875 13.57421875 C38.6171875 17.01171875 38.6171875 17.01171875 36.6171875 19.01171875 C34.55200195 19.55834961 34.55200195 19.55834961 31.98046875 19.9765625 C31.02132568 20.13543945 30.06218262 20.29431641 29.07397461 20.45800781 C28.03635986 20.62010742 26.99874512 20.78220703 25.9296875 20.94921875 C24.86564697 21.1203418 23.80160645 21.29146484 22.70532227 21.46777344 C15.1682882 22.66019807 7.61816829 23.7511583 0.0546875 24.76171875 C-1.36106567 24.95302368 -1.36106567 24.95302368 -2.80541992 25.14819336 C-17.31186215 27.01302693 -17.31186215 27.01302693 -23.3828125 23.01171875 C-27.02637584 19.12525119 -26.88210576 14.75758051 -26.73828125 9.67578125 C-26.23821448 5.92802806 -24.9286388 3.76969724 -22.3828125 1.01171875 C-15.84749844 -2.95266934 -7.24058287 -0.89735419 0 0 Z " fill="#FE7139" transform="translate(48.3828125,117.98828125)"/>
|
||||
<path d="M0 0 C2.69924999 1.79949999 3.97268929 2.98831891 5.625 5.75 C6.99344145 15.86914699 5.01332723 26.66429063 3.65356445 36.71191406 C3.30949244 39.27238125 2.98705575 41.83499184 2.66601562 44.3984375 C2.44920051 46.04704435 2.23114667 47.69548888 2.01171875 49.34375 C1.91783371 50.10006714 1.82394867 50.85638428 1.72721863 51.63562012 C1.09337 56.17414888 0.04108984 59.76523478 -2.375 63.75 C-5.4582635 65.29163175 -7.98378099 65.04747535 -11.375 64.75 C-15.22166785 61.31318029 -15.3272904 56.46613107 -15.98046875 51.62890625 C-16.10940018 50.73316391 -16.2383316 49.83742157 -16.37117004 48.91453552 C-16.64146628 47.01650682 -16.90586639 45.11763044 -17.16479492 43.21801758 C-17.55711089 40.35187129 -17.97132834 37.48954926 -18.38867188 34.62695312 C-22.01941843 9.12762749 -22.01941843 9.12762749 -19.36669922 3.67895508 C-14.53713333 -2.53836515 -6.89205685 -2.55261365 0 0 Z " fill="#FE723B" transform="translate(119.375,41.25)"/>
|
||||
<path d="M0 0 C2.90652991 2.90652991 2.876536 6.26706827 3.4375 10.1875 C3.62372925 11.43889771 3.62372925 11.43889771 3.8137207 12.71557617 C4.21906167 15.47570672 4.60956571 18.23772456 5 21 C5.14115234 21.96454102 5.28230469 22.92908203 5.42773438 23.92285156 C9.97022281 55.07281183 9.97022281 55.07281183 5.8125 61.25 C0.715773 64.4212968 -2.9058144 65.63043299 -9 65 C-12.29013805 63.71053175 -15.20895719 62.21495778 -17.18669128 59.17736816 C-19.85591311 52.03139945 -17.79685661 42.79141254 -16.79296875 35.40625 C-16.60387688 33.9753302 -16.60387688 33.9753302 -16.41096497 32.51550293 C-16.14485659 30.52187219 -15.87493824 28.52874651 -15.60131836 26.53613281 C-15.18498991 23.48158379 -14.79028617 20.42471259 -14.39648438 17.3671875 C-14.134117 15.42169863 -13.87049062 13.47637904 -13.60546875 11.53125 C-13.48970993 10.61879517 -13.37395111 9.70634033 -13.25468445 8.76623535 C-13.13478653 7.92105347 -13.01488861 7.07587158 -12.89135742 6.20507812 C-12.7914299 5.46326294 -12.69150238 4.72144775 -12.58854675 3.95715332 C-11.8654499 1.55256711 -10.95241701 0.55505439 -9 -1 C-5.19825711 -2.26724763 -3.6488868 -1.4750819 0 0 Z " fill="#FE723B" transform="translate(117,156)"/>
|
||||
<path d="M0 0 C5.94 0 11.88 0 18 0 C18 23.76 18 47.52 18 72 C12.06 72 6.12 72 0 72 C0 48.24 0 24.48 0 0 Z " fill="#070708" transform="translate(330,106)"/>
|
||||
<path d="M0 0 C5.04330987 4.1028907 7.71774978 9.39439884 10.75 15.0625 C11.24371094 15.96033203 11.73742187 16.85816406 12.24609375 17.78320312 C17.43420605 27.34212236 17.43420605 27.34212236 16.3125 31.625 C15 34 15 34 13.3125 35.375 C7.93375248 36.82871554 3.75347415 35.06129862 -0.91796875 32.453125 C-2.68187675 31.39768267 -4.43827823 30.32960785 -6.1875 29.25 C-7.07630859 28.7240625 -7.96511719 28.198125 -8.88085938 27.65625 C-13.35431143 24.94058564 -17.21257396 22.56892147 -20 18 C-20.61105403 13.79900352 -20.33783835 11.64968914 -18.375 7.875 C-13.06220859 1.44372619 -8.6335148 -2.08395185 0 0 Z " fill="#FE713A" transform="translate(63,62)"/>
|
||||
<path d="M0 0 C6.03297915 0.33114122 9.30476624 2.08342832 13.6171875 6.29296875 C16.38968616 9.47219093 17.04213903 11.96541114 17.65234375 16.0625 C17.06814734 20.15187485 15.13009417 22.36369578 12.21484375 25.125 C9.21149624 27.28908872 6.06168268 29.11960481 2.83984375 30.9375 C2.01484375 31.42669922 1.18984375 31.91589844 0.33984375 32.41992188 C-4.43543981 35.16002506 -8.25324942 36.89804998 -13.78515625 37.125 C-16.58087127 36.16857118 -17.46165114 35.77201022 -18.78515625 33.125 C-19.67120353 26.26932103 -16.95954186 21.84338012 -13.59765625 16 C-13.11490234 15.11957031 -12.63214844 14.23914063 -12.13476562 13.33203125 C-9.4652036 8.58056917 -6.87140939 4.73948324 -2.78515625 1.125 C-1.78515625 0.125 -1.78515625 0.125 0 0 Z " fill="#FE713A" transform="translate(163.78515625,60.875)"/>
|
||||
<path d="M0 0 C2.78888858 1.43213197 3.75957557 2.28489145 4.76171875 5.265625 C5.06866999 8.7880163 4.15658074 10.4564111 2.5 13.5625 C1.94570312 14.60535156 1.39140625 15.64820313 0.8203125 16.72265625 C0.21960938 17.80417969 -0.38109375 18.88570312 -1 20 C-1.60070312 21.09183594 -2.20140625 22.18367188 -2.8203125 23.30859375 C-8.27249431 32.95022261 -8.27249431 32.95022261 -12 36 C-16.40910476 37.09025136 -20.18006761 36.37843033 -24.25 34.5 C-28.79178106 30.37110812 -31.58989016 26.41964808 -32.625 20.3125 C-31.84891159 16.19923141 -30.15611216 14.52356363 -27 12 C-23.60919652 9.73474885 -20.10610667 7.70297221 -16.5625 5.6875 C-15.63373047 5.13771484 -14.70496094 4.58792969 -13.74804688 4.02148438 C-5.16887017 -0.89398698 -5.16887017 -0.89398698 0 0 Z " fill="#FE713A" transform="translate(75,163)"/>
|
||||
<path d="M0 0 C2.54541016 1.03808594 2.54541016 1.03808594 5.0390625 2.484375 C5.94914063 3.00773438 6.85921875 3.53109375 7.796875 4.0703125 C9.19679687 4.90175781 9.19679687 4.90175781 10.625 5.75 C11.56601563 6.28882813 12.50703125 6.82765625 13.4765625 7.3828125 C23.65542022 13.31084043 23.65542022 13.31084043 26 18 C26.23675113 23.05412198 25.48527501 26.97865449 22.25 30.9375 C18.41837476 34.85992204 15.48689762 35.78758605 10.0625 36.5625 C7.57335027 36.49114109 6.40429881 36.33826832 4.47973633 34.72802734 C0.68248769 30.09704219 -2.45567147 25.28818627 -5.375 20.0625 C-5.82488281 19.30259766 -6.27476562 18.54269531 -6.73828125 17.75976562 C-9.65990878 12.56033427 -10.65491643 8.96628872 -10 3 C-7.14254571 -0.51686681 -4.31419574 -0.2967049 0 0 Z " fill="#FE713A" transform="translate(155,163)"/>
|
||||
<path d="M0 0 C3.53733123 2.55892046 4.75963588 4.1887711 6 8.375 C6 12.7289874 5.17207574 15.23506872 3 19 C0.12121032 21.47895778 -1.66317695 21.95713161 -5.4375 22.4375 C-9.70968693 21.91284546 -11.75031771 20.79623825 -15 18 C-17.10863429 14.35781349 -17.50962203 11.16191328 -17 7 C-15.27978264 3.68698879 -13.9086019 1.58049566 -10.75 -0.4375 C-6.93940558 -1.21693977 -3.72934532 -1.15270673 0 0 Z " fill="#060608" transform="translate(548,158)"/>
|
||||
<path d="M0 0 C2.66915647 2.14834545 3.77943987 3.55633371 4.30078125 6.96875 C4.43294773 10.69723583 4.46252586 14.10920878 2 17 C-2.01955829 19.36444606 -4.10903676 20.14849387 -8.75 19.375 C-12.50920399 17.78456754 -14.19878159 16.60243682 -16 13 C-16.4128469 8.70639223 -16.2493583 6.46432234 -14.1875 2.625 C-10.27705848 -2.06752982 -5.52304925 -1.42562463 0 0 Z " fill="#060608" transform="translate(345,74)"/>
|
||||
<path d="M0 0 C5.61 0 11.22 0 17 0 C19 5 19 5 19 8 C18.01 8 17.02 8 16 8 C16 5.69 16 3.38 16 1 C11.05 1 6.1 1 1 1 C1 34 1 67 1 101 C5.62 101 10.24 101 15 101 C15 101.33 15 101.66 15 102 C10.05 102 5.1 102 0 102 C0 68.34 0 34.68 0 0 Z " fill="#000000" transform="translate(649,106)"/>
|
||||
<path d="M0 0 C1.48831242 0.00667405 2.97660807 0.01863442 4.46484375 0.03515625 C5.22345703 0.03966797 5.98207031 0.04417969 6.76367188 0.04882812 C8.64326246 0.06064945 10.52281274 0.07858547 12.40234375 0.09765625 C13.52283021 3.69273097 13.52281589 7.0427002 13.51586914 10.79394531 C13.51601517 11.45777222 13.51616119 12.12159912 13.51631165 12.80554199 C13.51561707 14.99148875 13.50784606 17.17734944 13.5 19.36328125 C13.4981342 20.88214064 13.49671106 22.40100063 13.49571228 23.91986084 C13.49189985 27.91143464 13.48208045 31.90297187 13.4710083 35.89453125 C13.46076678 39.97004705 13.45620879 44.04556878 13.45117188 48.12109375 C13.44045702 56.11329987 13.42287055 64.1054693 13.40234375 72.09765625 C7.46234375 72.09765625 1.52234375 72.09765625 -4.59765625 72.09765625 C-4.92765625 69.78765625 -5.25765625 67.47765625 -5.59765625 65.09765625 C-4.11265625 64.60265625 -4.11265625 64.60265625 -2.59765625 64.09765625 C-2.59765625 66.40765625 -2.59765625 68.71765625 -2.59765625 71.09765625 C2.02234375 71.09765625 6.64234375 71.09765625 11.40234375 71.09765625 C11.40234375 47.99765625 11.40234375 24.89765625 11.40234375 1.09765625 C6.78234375 1.09765625 2.16234375 1.09765625 -2.59765625 1.09765625 C-2.59765625 3.40765625 -2.59765625 5.71765625 -2.59765625 8.09765625 C-3.25765625 8.09765625 -3.91765625 8.09765625 -4.59765625 8.09765625 C-4.64007348 5.7647085 -4.63858562 3.43063058 -4.59765625 1.09765625 C-3.59765625 0.09765625 -3.59765625 0.09765625 0 0 Z " fill="#000000" transform="translate(622.59765625,105.90234375)"/>
|
||||
<path d="M0 0 C0.66 0.66 1.32 1.32 2 2 C0.3603125 1.9071875 0.3603125 1.9071875 -1.3125 1.8125 C-4.8362826 1.70189764 -4.8362826 1.70189764 -6.875 3.125 C-8.40430823 5.67384706 -8.67470941 8.07238473 -9 11 C-9.33 11 -9.66 11 -10 11 C-10.48541424 3.59743291 -10.48541424 3.59743291 -8.375 1 C-5.38358852 -0.25954167 -3.21567216 -0.34952958 0 0 Z " fill="#FE5D28" transform="translate(155,163)"/>
|
||||
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.38125 1.598125 1.7625 2.19625 1.125 2.8125 C-1.13676954 5.00811144 -1.13676954 5.00811144 -3 8 C-3.36923077 3.44615385 -3.36923077 3.44615385 -1.5 1.1875 C-1.005 0.795625 -0.51 0.40375 0 0 Z " fill="#FE6B2D" transform="translate(139,124)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,4 +1,5 @@
|
||||
import {Footer, Navbar} from '../';
|
||||
import {FAQSection, Footer, Navbar} from '../';
|
||||
import type {FAQ} from '../FAQSection';
|
||||
import {motion} from 'framer-motion';
|
||||
import React, {ReactNode, useLayoutEffect, useState} from 'react';
|
||||
import Link from 'next/link';
|
||||
@@ -13,6 +14,7 @@ interface GuideLayoutProps {
|
||||
children: ReactNode;
|
||||
canonical?: string;
|
||||
ogImage?: string;
|
||||
faqs?: FAQ[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,8 +27,12 @@ export function GuideLayout({
|
||||
readTime,
|
||||
children,
|
||||
canonical,
|
||||
ogImage = 'https://www.useplunk.com/assets/card.png',
|
||||
ogImage,
|
||||
faqs,
|
||||
}: GuideLayoutProps) {
|
||||
const resolvedOgImage =
|
||||
ogImage ||
|
||||
`https://www.useplunk.com/api/og?title=${encodeURIComponent(title)}&description=${encodeURIComponent(description)}&tag=Guide`;
|
||||
const [headings, setHeadings] = useState<{id: string; text: string; level: number}[]>([]);
|
||||
const [activeId, setActiveId] = useState<string>('');
|
||||
|
||||
@@ -90,7 +96,7 @@ export function GuideLayout({
|
||||
description: description,
|
||||
url: canonical,
|
||||
type: 'article',
|
||||
images: [{url: ogImage, alt: title}],
|
||||
images: [{url: resolvedOgImage, alt: title, width: 1200, height: 630}],
|
||||
article: {
|
||||
publishedTime: lastUpdated,
|
||||
modifiedTime: lastUpdated,
|
||||
@@ -103,7 +109,7 @@ export function GuideLayout({
|
||||
type="Article"
|
||||
url={canonical || ''}
|
||||
title={title}
|
||||
images={[ogImage]}
|
||||
images={[resolvedOgImage]}
|
||||
datePublished={lastUpdated}
|
||||
dateModified={lastUpdated}
|
||||
authorName="Plunk"
|
||||
@@ -241,6 +247,13 @@ export function GuideLayout({
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{faqs && faqs.length > 0 && (
|
||||
<FAQSection
|
||||
faqs={faqs}
|
||||
schemaId={`faq-${canonical ? canonical.split('/').pop() : 'guide'}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -36,6 +36,13 @@ function negotiate(accept: string): Negotiated {
|
||||
export function middleware(request: NextRequest) {
|
||||
const accept = request.headers.get('accept') ?? '';
|
||||
const { pathname } = request.nextUrl;
|
||||
|
||||
if (pathname.endsWith('.md')) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.set('x-md-path', pathname.slice(0, -3));
|
||||
return NextResponse.rewrite(new URL('/api/md', request.url), { request: { headers } });
|
||||
}
|
||||
|
||||
const result = negotiate(accept);
|
||||
|
||||
if (result === 'none') {
|
||||
@@ -56,5 +63,5 @@ export function middleware(request: NextRequest) {
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js)).*)',],
|
||||
matcher: ['/((?!api|_next|favicon\\.ico|assets|.*\\.(?:png|jpg|jpeg|gif|svg|ico|webp|woff|woff2|ttf|css|js|xml|txt|webmanifest)).*)',],
|
||||
};
|
||||
|
||||
@@ -82,7 +82,14 @@ export default function WithProviders(props: AppProps) {
|
||||
title: 'Plunk | The Open-Source Email Platform',
|
||||
description:
|
||||
'Open-source email automation platform with workflows, segments, and developer API. Scale from 0 to millions of emails at $0.001 per email. Self-hostable and privacy-first.',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk'}],
|
||||
images: [
|
||||
{
|
||||
url: `https://www.useplunk.com/api/og?title=${encodeURIComponent('The Open-Source Email Platform')}`,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk',
|
||||
},
|
||||
],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -7,12 +7,13 @@ export default class MyDocument extends Document {
|
||||
<Html lang="en">
|
||||
<Head>
|
||||
{/* Start favicon */}
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon/favicon-32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon/favicon-16x16.png" />
|
||||
<link rel="manifest" href="/favicon/site.webmanifest" />
|
||||
<link rel="mask-icon" href="/favicon/safari-pinned-tab.svg" color="#171717" />
|
||||
<link rel="shortcut icon" href="/favicon/favicon.ico" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<meta name="msapplication-TileColor" content="#ffffff" />
|
||||
<meta name="msapplication-config" content="/favicon/browserconfig.xml" />
|
||||
<meta name="theme-color" content="#171717" />
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import {ImageResponse} from 'next/og';
|
||||
import type {NextRequest} from 'next/server';
|
||||
|
||||
import {getBricolageFont} from '../../lib/og-font';
|
||||
|
||||
export const config = {runtime: 'edge'};
|
||||
|
||||
const PLUNK_P_PATH =
|
||||
'M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z';
|
||||
|
||||
export default function handler(req: NextRequest) {
|
||||
const {searchParams} = new URL(req.url);
|
||||
const title = searchParams.get('title') || 'The Open-Source Email Platform';
|
||||
const description = searchParams.get('description') || '';
|
||||
const tag = searchParams.get('tag') || '';
|
||||
|
||||
const fontData = getBricolageFont();
|
||||
|
||||
const titleLength = title.length;
|
||||
const fontSize = titleLength < 35 ? 144 : titleLength < 55 ? 120 : titleLength < 75 ? 100 : 84;
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: '2400px',
|
||||
height: '1260px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
padding: '128px 144px',
|
||||
backgroundColor: '#ffffff',
|
||||
fontFamily: '"Bricolage Grotesque"',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Ghost P watermark */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '-120px',
|
||||
right: '-60px',
|
||||
opacity: 0.035,
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<svg width="960" height="960" viewBox="0 0 1080 1080" fill="none">
|
||||
<path d={PLUNK_P_PATH} fill="#000000" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Top row: logo + URL */}
|
||||
<div style={{display: 'flex', justifyContent: 'space-between', alignItems: 'center'}}>
|
||||
<div style={{display: 'flex', alignItems: 'center', gap: '20px'}}>
|
||||
<svg width="60" height="60" viewBox="0 0 1080 1080" fill="none">
|
||||
<path d={PLUNK_P_PATH} fill="#171717" />
|
||||
</svg>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '44px',
|
||||
fontWeight: 800,
|
||||
color: '#171717',
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
>
|
||||
Plunk
|
||||
</span>
|
||||
</div>
|
||||
<span style={{fontSize: '30px', color: '#a3a3a3', letterSpacing: '0.01em'}}>
|
||||
useplunk.com
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div style={{display: 'flex', flexDirection: 'column', gap: '36px', maxWidth: '1840px'}}>
|
||||
{tag ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '12px 28px',
|
||||
backgroundColor: '#f4f4f5',
|
||||
borderRadius: '12px',
|
||||
fontSize: '30px',
|
||||
color: '#52525b',
|
||||
fontWeight: 600,
|
||||
width: 'fit-content',
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontSize: `${fontSize}px`,
|
||||
fontWeight: 800,
|
||||
color: '#0a0a0a',
|
||||
lineHeight: 1.08,
|
||||
letterSpacing: '-0.04em',
|
||||
whiteSpace: 'pre-wrap',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</div>
|
||||
{description ? (
|
||||
<div
|
||||
style={{
|
||||
fontSize: '44px',
|
||||
color: '#737373',
|
||||
lineHeight: 1.5,
|
||||
fontWeight: 400,
|
||||
letterSpacing: '-0.01em',
|
||||
}}
|
||||
>
|
||||
{description}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Bottom spacer */}
|
||||
<div style={{display: 'flex'}} />
|
||||
</div>
|
||||
),
|
||||
{
|
||||
width: 2400,
|
||||
height: 1260,
|
||||
fonts: [
|
||||
{
|
||||
name: 'Bricolage Grotesque',
|
||||
data: fontData,
|
||||
style: 'normal',
|
||||
weight: 800,
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -79,6 +79,9 @@ export default function EmailEditorFeature() {
|
||||
property="og:description"
|
||||
content="The email editor that speaks both languages. Switch seamlessly between visual and code editing, preview with real data, and create templates that work everywhere."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Editor&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -78,6 +78,9 @@ export default function InboundEmailFeature() {
|
||||
property="og:description"
|
||||
content="Receive emails at your custom domain and automatically process them. Capture leads, create support tickets, and trigger workflows from incoming emails."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Inbound+Email+Processing&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -93,6 +93,9 @@ export default function SegmentsFeature() {
|
||||
property="og:description"
|
||||
content="Create dynamic and static segments to organize your contacts. Filter by behavior, attributes, and engagement."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Audience+Segmentation&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -89,6 +89,9 @@ export default function SMTPFeature() {
|
||||
property="og:description"
|
||||
content="Send emails via SMTP or API. Works with any email client or application. Secure TLS/SSL connections with automatic domain validation and full tracking."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=SMTP+Email+Sending&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -78,6 +78,9 @@ export default function WorkflowsFeature() {
|
||||
property="og:description"
|
||||
content="Build sophisticated email automation workflows with visual no-code builder. Create event-driven sequences, conditional branching, and time-based delays."
|
||||
/>
|
||||
<meta property="og:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
|
||||
<meta property="twitter:card" content="summary_large_image" />
|
||||
<meta property="twitter:image" content="https://www.useplunk.com/api/og?title=Email+Workflow+Automation&tag=Feature" />
|
||||
</Head>
|
||||
|
||||
<Navbar />
|
||||
|
||||
@@ -6,8 +6,8 @@ import Link from 'next/link';
|
||||
export default function EmailAPIGuide() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="Email API Guide: Everything You Need to Know with Code Examples"
|
||||
description="Complete guide to email APIs: how they work, implementation examples, best practices, and choosing the right solution for your application."
|
||||
title="What is an Email API? Complete Guide with Code Examples | Plunk"
|
||||
description="An email API lets you send, receive, and manage emails programmatically. Learn how they work, see code examples in Node.js, Python, PHP, Ruby, and Go, and choose the right provider."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-api-guide"
|
||||
|
||||
@@ -2,6 +2,35 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a good email bounce rate?',
|
||||
answer:
|
||||
'A bounce rate below 2% is considered excellent. Between 2–5% is concerning and needs attention. Above 5% is critical and will seriously damage your sender reputation and deliverability. For hard bounces specifically, keep this below 0.5% per campaign. Most reputable email platforms will suspend accounts or pause sending if hard bounce rates exceed 5–10%.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between a hard bounce and a soft bounce?',
|
||||
answer:
|
||||
'A hard bounce is a permanent delivery failure—the email address does not exist, the domain is invalid, or the receiving server has permanently blocked delivery. Remove hard bounce addresses immediately and never send to them again. A soft bounce is a temporary failure—the mailbox is full, the server is temporarily down, or the email is too large. Email platforms typically retry soft bounces automatically for 24–72 hours.',
|
||||
},
|
||||
{
|
||||
question: 'How do I reduce email bounce rates?',
|
||||
answer:
|
||||
'Key strategies to reduce bounces: (1) Use double opt-in to confirm email addresses at signup, (2) Validate email addresses in real-time on forms to catch typos and invalid formats, (3) Remove hard bounces immediately and automatically, (4) Clean your list every 6–12 months to remove stale addresses (15–20% of email addresses go invalid per year), (5) Never purchase email lists—they have 20–40% invalid addresses, and (6) Maintain consistent sending to prevent addresses going stale.',
|
||||
},
|
||||
{
|
||||
question: 'Do bounces affect my email deliverability?',
|
||||
answer:
|
||||
'Yes, high bounce rates directly damage your sender reputation. Email providers like Gmail and Outlook track bounce rates as a signal of list quality and sending practices. High bounce rates cause more of your emails—even to valid addresses—to land in spam. Persistent high bounce rates can lead to your sending IP being blacklisted, making it extremely difficult to deliver any emails.',
|
||||
},
|
||||
{
|
||||
question: 'Why are my emails bouncing suddenly?',
|
||||
answer:
|
||||
'Sudden increases in bounce rates can be caused by: sending to an old list that has gone stale (addresses expire over time), sending to a purchased list, a domain or server configuration change, your sending IP or domain being blacklisted, or reaching inbox provider sending limits. Identify the pattern—are bounces happening on specific domains or across all recipients?—then investigate the root cause.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailBounceRate() {
|
||||
return (
|
||||
@@ -11,6 +40,7 @@ export default function EmailBounceRate() {
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/email-bounce-rate"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a good email open rate?',
|
||||
answer:
|
||||
'A good email open rate depends on your industry and email type. For B2B emails, 25-35% is considered excellent. For B2C marketing emails, 20-30% is strong. Overall industry averages range from 20-30%, though Apple Mail Privacy Protection has inflated reported rates by 5-15 percentage points since 2021. Compare against your own historical data rather than industry averages alone.',
|
||||
},
|
||||
{
|
||||
question: 'What are average B2B email open rates?',
|
||||
answer:
|
||||
'Average B2B email open rates typically range from 20-30%, with top-performing campaigns achieving 35-45%. B2B emails tend to outperform B2C because recipients opted in for professional reasons and have higher intent. Industry-specific rates vary: SaaS averages 22-28%, financial services 20-25%, and professional services 25-30%. Transactional B2B emails like invoices and notifications often exceed 50%.',
|
||||
},
|
||||
{
|
||||
question: 'How do I improve my email open rate?',
|
||||
answer:
|
||||
'The most impactful ways to improve email open rates are: (1) Write compelling, specific subject lines under 50 characters, (2) Personalize beyond first names—use behavioral data and purchase history, (3) Send at optimal times (Tuesday–Thursday, 10am–2pm for B2B), (4) Segment your list to send relevant content to specific groups, (5) Keep your list clean by removing inactive subscribers, (6) Use a recognizable sender name, and (7) Optimize preview text to complement the subject line.',
|
||||
},
|
||||
{
|
||||
question: 'Why are my email open rates low?',
|
||||
answer:
|
||||
'Low email open rates typically result from: weak subject lines that fail to create curiosity or urgency, poor list hygiene with too many inactive subscribers dragging down averages, emails landing in spam due to deliverability or authentication issues, bad send timing, email fatigue from sending too frequently, or poor segmentation sending irrelevant content. Review each factor systematically and A/B test changes one at a time.',
|
||||
},
|
||||
{
|
||||
question: 'Does Apple Mail Privacy Protection affect open rates?',
|
||||
answer:
|
||||
'Yes. Apple Mail Privacy Protection (MPP), launched in iOS 15 in 2021, pre-loads email images and tracking pixels, registering "opens" even if the user never actually read the email. This inflates open rates by 5-15 percentage points across the industry. To adapt, focus on click-through rates and conversions as more reliable engagement metrics, and segment by email client to understand true engagement patterns.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function EmailOpenRate() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="Email Open Rate: Benchmarks, Strategies & Best Practices"
|
||||
description="Learn what affects email open rates, industry benchmarks, and proven tactics to improve opens. Complete guide with actionable tips."
|
||||
title="Email Open Rates: Industry Benchmarks & How to Improve Yours"
|
||||
description="Learn what affects email open rates, industry benchmarks by sector, B2B vs B2C averages, and proven tactics to improve your open rates. Complete guide with actionable tips."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="10 min"
|
||||
readTime="12 min"
|
||||
canonical="https://www.useplunk.com/guides/email-open-rate"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -126,6 +156,86 @@ Open Rate = (285 ÷ 950) × 100 = 30%`}
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* B2B Email Open Rates */}
|
||||
<section id="b2b-email-open-rates" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">B2B Email Open Rates</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
B2B (business-to-business) emails consistently outperform B2C benchmarks. Recipients opted in because of
|
||||
genuine professional need, making them more engaged audiences.
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">B2B Industry</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Average Open Rate</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold">Top Performers</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 bg-white">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">SaaS / Technology</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">22–28%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">35%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Professional Services</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">25–32%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">40%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Financial Services</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">20–26%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">33%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Agency / Marketing</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">18–24%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">30%+</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm text-neutral-900">Transactional / Receipts</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">45–65%</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">70%+</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 mb-6">
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B-Specific Tactics</h3>
|
||||
<ul className="space-y-2 text-sm text-neutral-700">
|
||||
<li>• Send Tuesday–Thursday, 10am–2pm in recipient's timezone</li>
|
||||
<li>• Use company/product name in subject line for recognition</li>
|
||||
<li>• Personalize with job title, industry, or company size</li>
|
||||
<li>• Reference specific pain points relevant to their role</li>
|
||||
<li>• Keep frequency lower—1–2 times per month for cold lists</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 bg-white p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-3">B2B vs B2C Differences</h3>
|
||||
<ul className="space-y-2 text-sm text-neutral-700">
|
||||
<li>• B2B decisions are more considered—longer subject lines acceptable</li>
|
||||
<li>• B2B audiences check email less frequently on weekends</li>
|
||||
<li>• B2B recipients share inboxes with colleagues, affecting metrics</li>
|
||||
<li>• B2B unsubscribe rates are typically lower (0.1–0.3% vs 0.3–0.5%)</li>
|
||||
<li>• B2B transactional emails (invoices, updates) have much higher opens</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="Segment Transactional vs Marketing">
|
||||
<p>
|
||||
Keep transactional emails (invoices, notifications, password resets) in separate campaigns from marketing
|
||||
emails. Transactional emails typically achieve 40–60% open rates, which will skew your marketing
|
||||
benchmarks if mixed together.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* Factors Affecting */}
|
||||
<section id="factors" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">What Affects Email Open Rates?</h2>
|
||||
|
||||
@@ -112,7 +112,7 @@ export default function GuidesIndex() {
|
||||
description:
|
||||
'Learn email best practices, authentication (DKIM, SPF, DMARC), deliverability optimization, and more.',
|
||||
url: 'https://www.useplunk.com/guides',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Guides'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Email+Guides+for+Developers&tag=Guide', alt: 'Plunk Guides', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DKIM?',
|
||||
answer:
|
||||
'DKIM (DomainKeys Identified Mail) is an email authentication method that adds a cryptographic digital signature to your outgoing emails. It allows receiving mail servers to verify that an email was genuinely sent from your domain and that its content was not altered in transit, improving deliverability and protecting against email spoofing.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DKIM key?',
|
||||
answer:
|
||||
'A DKIM key is a cryptographic key pair used to sign and verify emails. The private key is stored securely on your mail server and used to sign outgoing emails. The public key is published as a TXT record in your domain\'s DNS under selector._domainkey.yourdomain.com and is used by receiving servers to verify the signature. You should never share or expose your DKIM private key.',
|
||||
},
|
||||
{
|
||||
question: 'How do I set up DKIM?',
|
||||
answer:
|
||||
'Setting up DKIM involves three steps: (1) Generate a DKIM key pair for your domain—most email providers do this automatically, (2) Publish the public key as a TXT record in your DNS at selector._domainkey.yourdomain.com, and (3) Configure your mail server or email service provider to sign outgoing emails with the private key. Platforms like Plunk handle all of this automatically when you add your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between DKIM and SPF?',
|
||||
answer:
|
||||
'SPF (Sender Policy Framework) verifies that the sending mail server is authorized to send email for your domain by checking its IP address against your DNS record. DKIM adds a cryptographic signature to the email that verifies the content has not been altered in transit. They are complementary: SPF validates the server identity, DKIM validates the message integrity. Using both together—along with DMARC—provides the strongest email authentication.',
|
||||
},
|
||||
{
|
||||
question: 'What happens if DKIM fails?',
|
||||
answer:
|
||||
'If DKIM verification fails, the receiving mail server may treat the email as suspicious. Depending on your DMARC policy, failed DKIM emails might be delivered normally (p=none), sent to spam (p=quarantine), or completely rejected (p=reject). Persistent DKIM failures damage your sender reputation and reduce email deliverability. Common causes include misconfigured DNS records, expired keys, or email modification during forwarding.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsDKIM() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="What is DKIM? Email Authentication Explained"
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) protects your emails from spoofing and improves deliverability. Complete guide with setup examples."
|
||||
description="Learn how DKIM (DomainKeys Identified Mail) works, what a DKIM key is, and how to set it up to protect your emails from spoofing and improve deliverability."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="8 min"
|
||||
readTime="10 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dkim"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
@@ -300,6 +330,119 @@ export default function WhatIsDKIM() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* DKIM vs SPF */}
|
||||
<section id="dkim-vs-spf" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">DKIM vs SPF: What's the Difference?</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
DKIM and SPF are both email authentication protocols, but they work differently and check different things:
|
||||
</p>
|
||||
|
||||
<div className="rounded-xl border border-neutral-200 overflow-hidden mb-8">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">Feature</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">DKIM</th>
|
||||
<th className="px-6 py-4 text-left text-sm font-semibold text-neutral-900">SPF</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200 bg-white">
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">What it validates</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Email content integrity</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Sending server authorization</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">How it works</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Cryptographic signature in headers</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">IP address check against DNS list</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Survives forwarding</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (if content unchanged)</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">No (forwarded IP changes)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">DNS record type</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">TXT at selector._domainkey.*</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">TXT at root domain</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-6 py-4 text-sm font-medium text-neutral-900">Required for DMARC</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
|
||||
<td className="px-6 py-4 text-sm text-neutral-700">Yes (one of SPF/DKIM required)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<InfoBox type="tip" title="Use Both for Maximum Protection">
|
||||
<p>
|
||||
DKIM and SPF complement each other. SPF covers scenarios where DKIM can't (like forged server IPs), and DKIM
|
||||
covers forwarding scenarios where SPF breaks. Implementing both—plus DMARC—gives you complete email
|
||||
authentication coverage.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* How to Test DKIM */}
|
||||
<section id="test-dkim" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">How to Test Your DKIM Setup</h2>
|
||||
<p className="text-neutral-700 leading-relaxed mb-6">
|
||||
After setting up DKIM, verify it's working correctly using these methods:
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">1. Check the Email Headers</h3>
|
||||
<p className="text-neutral-700 mb-4">
|
||||
Send a test email and view the raw message headers. Look for the <code>DKIM-Signature</code> header and the{' '}
|
||||
<code>Authentication-Results</code> header which shows whether DKIM passed or failed:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="text"
|
||||
title="Example Authentication-Results Header"
|
||||
code={`Authentication-Results: mx.google.com;
|
||||
dkim=pass [email protected] header.s=default header.b=GJwP3Qr8;
|
||||
spf=pass (google.com: domain of [email protected] designates 1.2.3.4 as permitted sender);
|
||||
dmarc=pass (p=QUARANTINE sp=QUARANTINE dis=NONE) header.from=yourdomain.com`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">2. Use DNS Lookup Tools</h3>
|
||||
<p className="text-neutral-700">
|
||||
Verify your DKIM public key is correctly published in DNS by querying your DKIM TXT record:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
title="DNS Lookup Command"
|
||||
code={`# Check your DKIM record via DNS
|
||||
dig TXT default._domainkey.yourdomain.com
|
||||
|
||||
# Or using nslookup
|
||||
nslookup -type=TXT default._domainkey.yourdomain.com`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="">
|
||||
<h3 className="text-xl font-semibold text-neutral-900 mb-3">3. Send to Gmail and Check</h3>
|
||||
<p className="text-neutral-700">
|
||||
Send a test email to a Gmail address, then click the three-dot menu and select "Show original". The
|
||||
"Summary" at the top will show DKIM: PASS or DKIM: FAIL, confirming your setup is working.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InfoBox type="info" title="Common DKIM Issues">
|
||||
<p>
|
||||
If DKIM fails, check that: (1) The DNS record uses the correct selector name, (2) The record hasn't been
|
||||
truncated by your DNS provider (long keys may need to be split), (3) DNS propagation is complete (can take
|
||||
up to 48 hours), and (4) Your email service is configured to sign with the correct private key.
|
||||
</p>
|
||||
</InfoBox>
|
||||
</section>
|
||||
|
||||
{/* Related Guides */}
|
||||
<section id="related-guides" className="mb-12">
|
||||
<h2 className="text-3xl font-bold text-neutral-900 mb-6">Related Email Authentication Guides</h2>
|
||||
|
||||
@@ -2,15 +2,45 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is DMARC?',
|
||||
answer:
|
||||
'DMARC (Domain-based Message Authentication, Reporting, and Conformance) is an email authentication protocol that builds on SPF and DKIM to protect your domain from spoofing and phishing. It tells receiving mail servers what to do when an email fails authentication checks—none (monitor), quarantine (spam), or reject (block)—and provides detailed reports about who is sending email using your domain.',
|
||||
},
|
||||
{
|
||||
question: 'What is a DMARC policy?',
|
||||
answer:
|
||||
'A DMARC policy (the "p=" tag) determines how receiving mail servers handle emails that fail authentication. p=none means monitor only and take no action; p=quarantine sends failed emails to spam; p=reject completely blocks failed emails. Start with p=none to gather data, then progressively tighten to quarantine and reject as you confirm all your legitimate senders are properly authenticated.',
|
||||
},
|
||||
{
|
||||
question: 'What does p=reject mean in DMARC?',
|
||||
answer:
|
||||
'p=reject is the strictest DMARC policy. Emails that fail DMARC authentication are completely rejected and not delivered to the recipient. This provides maximum protection against phishing and spoofing, but requires that all your legitimate email sources (newsletters, CRMs, transactional tools) are properly configured with SPF and DKIM before enabling it.',
|
||||
},
|
||||
{
|
||||
question: 'How do I add a DMARC record?',
|
||||
answer:
|
||||
'Add a TXT record in your DNS with the name _dmarc.yourdomain.com. Start with a monitoring-only value: v=DMARC1; p=none; rua=mailto:[email protected]. This records email activity without affecting delivery. After reviewing reports for 2-4 weeks and confirming your SPF and DKIM setup, gradually strengthen to p=quarantine then p=reject.',
|
||||
},
|
||||
{
|
||||
question: 'Is DMARC required for email deliverability?',
|
||||
answer:
|
||||
'Since February 2024, Google and Yahoo require DMARC authentication (with at least p=none) for bulk senders sending more than 5,000 emails per day to Gmail or Yahoo addresses. While not strictly required for smaller senders, implementing DMARC is strongly recommended for all domains to improve deliverability, prevent phishing, and meet industry best practices.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsDMARC() {
|
||||
return (
|
||||
<GuideLayout
|
||||
title="What is DMARC? Email Policy & Reporting Explained"
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain from email spoofing. Complete setup guide with policy examples."
|
||||
title="What is DMARC? Policy, Setup & Reporting Explained"
|
||||
description="Learn how DMARC works with SPF and DKIM to protect your domain. Understand DMARC policies (none, quarantine, reject), how to set up a DMARC record, and read reports."
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="9 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-dmarc"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -2,6 +2,35 @@ import React from 'react';
|
||||
import {GuideLayout, InfoBox} from '../../components/guides';
|
||||
import {CodeBlock} from '../../components/CodeBlock';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is SPF in email?',
|
||||
answer:
|
||||
'SPF (Sender Policy Framework) is an email authentication protocol that lets domain owners specify which mail servers are authorized to send email on behalf of their domain. It works by publishing a list of authorized IP addresses in your DNS as a TXT record. When an email arrives, the receiving server checks if the sending server\'s IP address is on your approved list.',
|
||||
},
|
||||
{
|
||||
question: 'What is the difference between SPF and DKIM?',
|
||||
answer:
|
||||
'SPF verifies that the sending mail server is authorized to send from your domain by checking the server\'s IP address against your DNS record. DKIM adds a cryptographic signature to the email body and headers, verifying the content was not modified in transit. SPF validates who is sending; DKIM validates what was sent. Both work best together, and DMARC requires at least one of them to be properly configured.',
|
||||
},
|
||||
{
|
||||
question: 'How do I create an SPF record?',
|
||||
answer:
|
||||
'Add a TXT record to your domain\'s DNS at the root domain (@ or yourdomain.com) with the format: v=spf1 include:[your-email-service] ~all. Replace the include: with the SPF value from your email provider. If you use multiple email services, combine them in one record: v=spf1 include:_spf.google.com include:spf.useplunk.com ~all. You can only have one SPF record per domain.',
|
||||
},
|
||||
{
|
||||
question: 'What does ~all mean in an SPF record?',
|
||||
answer:
|
||||
'~all (tilde-all) at the end of an SPF record is a "soft fail" qualifier, meaning emails from unlisted servers should be accepted but flagged as potentially suspicious. The alternative -all (hard fail) completely rejects emails from unlisted servers. Most experts recommend ~all for initial setup to avoid blocking legitimate emails. Never use +all—it passes all emails and completely defeats SPF\'s purpose.',
|
||||
},
|
||||
{
|
||||
question: 'Why is SPF failing even though I set it up correctly?',
|
||||
answer:
|
||||
'Common SPF failure causes: (1) You have multiple SPF records—you can only have one, combine all senders into a single record, (2) You exceeded the 10 DNS lookup limit—each include: counts as one lookup, (3) You added a new email service but forgot to add its SPF include, (4) DNS propagation is still in progress—can take up to 48 hours, (5) Your email is being forwarded, which changes the sending IP and breaks SPF (use DKIM too to handle forwarding).',
|
||||
},
|
||||
];
|
||||
|
||||
export default function WhatIsSPF() {
|
||||
return (
|
||||
@@ -11,6 +40,7 @@ export default function WhatIsSPF() {
|
||||
lastUpdated="2025-12-20"
|
||||
readTime="7 min"
|
||||
canonical="https://www.useplunk.com/guides/what-is-spf"
|
||||
faqs={faqs}
|
||||
>
|
||||
{/* Introduction */}
|
||||
<section id="introduction" className="mb-12">
|
||||
|
||||
@@ -3,6 +3,7 @@ import {motion} from 'framer-motion';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import {DASHBOARD_URI, LANDING_URI, WIKI_URI} from '../lib/constants';
|
||||
import React from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import Artur from '../../public/assets/artur.png';
|
||||
import Joe from '../../public/assets/joe.png';
|
||||
import Noah from '../../public/assets/noah.png';
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
Workflow
|
||||
Workflow,
|
||||
} from 'lucide-react';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
@@ -132,6 +133,14 @@ const competitors = [
|
||||
{name: 'Mailgun', slug: 'mailgun'},
|
||||
];
|
||||
|
||||
const customers: {name: string; url: string; logo: string; imgClassName?: string; label?: string}[] = [
|
||||
{name: 'Krumzi', url: 'https://krumzi.com', logo: '/assets/krumzi.svg'},
|
||||
{name: 'Waidwissen', url: 'https://waidwissen.com', logo: '/assets/waidwissen.svg'},
|
||||
{name: 'Dodo Payments', url: 'https://dodopayments.com', logo: '/assets/dodo.svg'},
|
||||
{name: 'SnowSEO', url: 'https://snowseo.com', logo: '/assets/snowseo.svg', imgClassName: 'h-9'},
|
||||
{name: 'Viral', url: 'https://viral.app', logo: '/assets/viral.svg', imgClassName: 'h-9'},
|
||||
];
|
||||
|
||||
const tickerItems = [
|
||||
'Open Source',
|
||||
'AGPL-3.0',
|
||||
@@ -146,6 +155,23 @@ const tickerItems = [
|
||||
export default function Index() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk — Open-Source Transactional Email Platform"
|
||||
description="Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits."
|
||||
openGraph={{
|
||||
title: 'Plunk — Open-Source Transactional Email Platform',
|
||||
description:
|
||||
'Send transactional emails, run marketing campaigns, and automate workflows — all open-source and self-hostable. $0.001/email, no contact limits.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.useplunk.com/api/og?title=The+Open-Source+Email+Platform',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk — The Open-Source Email Platform',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
<Script
|
||||
id={`faq-schema-index`}
|
||||
type="application/ld+json"
|
||||
@@ -248,9 +274,7 @@ export default function Index() {
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main
|
||||
className={'text-neutral-800'}
|
||||
>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
@@ -261,19 +285,6 @@ export default function Index() {
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
{/* Top meta row */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={
|
||||
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
|
||||
}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ 01 — Plunk</span>
|
||||
</motion.div>
|
||||
|
||||
{/* Centered hero — single clear message, CTA directly below */}
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
@@ -356,6 +367,52 @@ export default function Index() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CUSTOMER LOGO STRIP ========== */}
|
||||
<section aria-label="Customers">
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-14 pt-8 sm:px-10 sm:pb-16 sm:pt-10'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex flex-col items-center gap-4'}
|
||||
>
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-400'}
|
||||
>
|
||||
Built into products at
|
||||
</span>
|
||||
<div className={'flex flex-wrap items-center justify-center gap-10 sm:gap-14'}>
|
||||
{customers.map(c => (
|
||||
<a
|
||||
key={c.name}
|
||||
href={c.url}
|
||||
target={'_blank'}
|
||||
rel={'noopener noreferrer'}
|
||||
className={'group flex flex-col items-center gap-1.5'}
|
||||
aria-label={c.name}
|
||||
>
|
||||
<img
|
||||
src={c.logo}
|
||||
alt={c.name}
|
||||
className={`${c.imgClassName ?? 'h-7'} w-auto max-w-none grayscale opacity-40 transition-all duration-300 group-hover:grayscale-0 group-hover:opacity-100`}
|
||||
/>
|
||||
{c.label && (
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[10px] uppercase tracking-[0.18em] text-neutral-400 transition-colors duration-300 group-hover:text-neutral-600'}
|
||||
>
|
||||
{c.label}
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== MARQUEE TICKER ========== */}
|
||||
<section className={'overflow-hidden border-y border-neutral-900 bg-neutral-900 py-7'} aria-hidden>
|
||||
<div className={'flex'}>
|
||||
@@ -614,7 +671,10 @@ export default function Index() {
|
||||
className={'rounded-[24px] border border-neutral-200 bg-white p-8 text-left'}
|
||||
>
|
||||
<div className={'text-neutral-900'}>{item.icon}</div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mt-10 text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>{item.sub}</p>
|
||||
@@ -773,7 +833,10 @@ export default function Index() {
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h3 style={{fontFamily: 'var(--font-display)'}} className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-2xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'mt-2 text-sm text-neutral-600'}>{item.note}</p>
|
||||
@@ -963,9 +1026,7 @@ export default function Index() {
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={
|
||||
'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'
|
||||
}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Start sending in 5 minutes.
|
||||
</motion.h2>
|
||||
@@ -1014,4 +1075,3 @@ export default function Index() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,14 @@ export default function Pricing() {
|
||||
title: 'Plunk Pricing | The Open-Source Email Platform',
|
||||
description:
|
||||
'Transparent email pricing at $0.001 per email with no contact limits. Free plan includes 1,000 emails/month. No hidden fees.',
|
||||
images: [
|
||||
{
|
||||
url: 'https://www.useplunk.com/api/og?title=Simple%2C+Honest+Pricing&description=%240.001+per+email.+No+contact+limits.+Free+to+start.',
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: 'Plunk Pricing',
|
||||
},
|
||||
],
|
||||
}}
|
||||
additionalMetaTags={[{property: 'title', content: 'Plunk Pricing | The Open-Source Email Platform'}]}
|
||||
/>
|
||||
|
||||
@@ -4,7 +4,7 @@ import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {ArrowRight, ArrowUpRight, Code2, Search} from 'lucide-react';
|
||||
import {ArrowRight, ArrowUpRight, Code2, Search, ShieldAlert} from 'lucide-react';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
@@ -43,21 +43,28 @@ const tools = [
|
||||
icon: Search,
|
||||
number: '02',
|
||||
},
|
||||
{
|
||||
name: 'Spam Checker',
|
||||
slug: 'spam-checker',
|
||||
description: 'Test your email subject line and content for spam trigger words and deliverability issues.',
|
||||
icon: ShieldAlert,
|
||||
number: '03',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ToolsIndex() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Email Tools | Markdown to Email & Email Verification | Plunk"
|
||||
description="Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly. No sign-up required."
|
||||
title="Free Email Tools | Spam Checker, Email Validator & More | Plunk"
|
||||
description="Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required."
|
||||
canonical="https://www.useplunk.com/tools"
|
||||
openGraph={{
|
||||
title: 'Free Email Tools | Markdown to Email & Email Verification | Plunk',
|
||||
title: 'Free Email Tools | Spam Checker, Email Validator & More | Plunk',
|
||||
description:
|
||||
'Free tools for email developers: Convert markdown to email-safe HTML and verify email addresses instantly.',
|
||||
'Free email developer tools: spam checker, email address validator, and markdown-to-email converter. No sign-up required.',
|
||||
url: 'https://www.useplunk.com/tools',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Tools'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Developer+Tools&tag=Tool', alt: 'Plunk Email Tools', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -46,15 +46,15 @@ export default function MarkdownToEmail() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Markdown to Email HTML Converter | Plunk"
|
||||
description="Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview. Perfect for email developers and marketers."
|
||||
title="Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk"
|
||||
description="Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up required."
|
||||
canonical="https://www.useplunk.com/tools/markdown-to-email"
|
||||
openGraph={{
|
||||
title: 'Free Markdown to Email HTML Converter | Plunk',
|
||||
title: 'Markdown to Email-Safe HTML Converter | Free Online Tool | Plunk',
|
||||
description:
|
||||
'Convert markdown and rich text to email-safe HTML instantly. Free online tool with live preview.',
|
||||
'Convert Markdown to email-safe HTML instantly — handles inline styles, table layouts, and compatibility across email clients. Free, no sign-up.',
|
||||
url: 'https://www.useplunk.com/tools/markdown-to-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email HTML Converter'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Markdown+to+Email+HTML+Converter&tag=Tool', alt: 'Plunk Markdown to Email', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,544 @@
|
||||
import {FAQSection, Footer, Navbar, SectionHeader} from '../../components';
|
||||
import {motion} from 'framer-motion';
|
||||
import {DASHBOARD_URI} from '../../lib/constants';
|
||||
import React, {useState} from 'react';
|
||||
import {NextSeo} from 'next-seo';
|
||||
import {AlertTriangle, ArrowRight, CheckCircle, ShieldAlert, XCircle} from 'lucide-react';
|
||||
import {Button, Input} from '@plunk/ui';
|
||||
import {Bricolage_Grotesque, Hanken_Grotesk, JetBrains_Mono} from 'next/font/google';
|
||||
import Link from 'next/link';
|
||||
import type {FAQ} from '../../components/FAQSection';
|
||||
|
||||
const display = Bricolage_Grotesque({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-display',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700', '800'],
|
||||
});
|
||||
|
||||
const body = Hanken_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-body',
|
||||
display: 'swap',
|
||||
weight: ['400', '500', '600', '700'],
|
||||
});
|
||||
|
||||
const mono = JetBrains_Mono({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
weight: ['400', '500'],
|
||||
});
|
||||
|
||||
const SPAM_TRIGGER_WORDS = [
|
||||
'act now', 'act immediately', 'action required', 'apply now', 'apply online',
|
||||
'as seen on', 'avoid bankruptcy', 'be your own boss', 'being a member',
|
||||
'big bucks', 'bill 1618', 'billion dollars', 'bonus',
|
||||
'buy direct', 'buy now', 'buyer protection', 'buying judgments',
|
||||
'call free', 'call now', 'cancel at any time', 'can\'t live without',
|
||||
'cash bonus', 'cash prize', 'celebrate', 'cent on the dollar',
|
||||
'chance', 'cheap', 'check now', 'claim now', 'claim your', 'click below',
|
||||
'click here', 'click now', 'click to remove', 'compare rates',
|
||||
'compete for your business', 'congratulations', 'consolidate debt',
|
||||
'copy accurately', 'copy dvds', 'deal', 'dear friend',
|
||||
'earn per week', 'earn extra cash', 'earn extra income',
|
||||
'eliminate bad credit', 'eliminate debt', 'excl', 'exclusive deal',
|
||||
'exclusive offer', 'extra cash', 'extra income',
|
||||
'f r e e', 'fantastic deal', 'fast cash', 'financial freedom',
|
||||
'find out anything', 'for free', 'for just $', 'for only $',
|
||||
'free access', 'free bonus', 'free cell phone', 'free consultation',
|
||||
'free gift', 'free grant money', 'free info', 'free installation',
|
||||
'free investment', 'free leads', 'free membership', 'free money',
|
||||
'free offer', 'free preview', 'free prize', 'free quote', 'free sample',
|
||||
'free trial', 'freedom', 'full refund', 'get it now', 'get out of debt',
|
||||
'get paid', 'give it away', 'giving it away', 'great offer',
|
||||
'guaranteed', 'hidden assets', 'hidden charges',
|
||||
'home based business', 'hot deal', 'huge discount', 'incredible deal',
|
||||
'incredible offer', 'information you requested', 'instant access',
|
||||
'instant approval', 'insurance', 'it\'s effective', 'join millions',
|
||||
'joining for free', 'junk mail', 'last chance', 'limited time',
|
||||
'lose weight', 'lowest price', 'lowest rate', 'lucky winner',
|
||||
'luxury car', 'luxury gift', 'make $', 'make money', 'make money fast',
|
||||
'marketing solutions', 'mass email', 'meet singles', 'member',
|
||||
'million dollars', 'miracle', 'money back', 'money making',
|
||||
'monthly payment', 'mortgage', 'multi level marketing',
|
||||
'name brand', 'new customer', 'new domain', 'now only',
|
||||
'obligation free', 'off everything', 'offer expires',
|
||||
'once in a lifetime', 'one hundred percent free', 'one hundred percent satisfied',
|
||||
'open an account', 'opportunity', 'opt in', 'order now',
|
||||
'order status', 'order today', 'outstanding values',
|
||||
'pennies a day', 'per day', 'per month', 'per week',
|
||||
'potential earnings', 'price protection', 'prices reduced',
|
||||
'prize', 'profit', 'promise you', 'pure profit',
|
||||
'refinance', 'refund', 'register for free', 'remove',
|
||||
'reverses aging', 'risk free', 'satisfaction guaranteed',
|
||||
'save big money', 'save up to', 'selected', 'serious cash',
|
||||
'sign up free', 'special discount', 'special offer', 'special promotion',
|
||||
'subject to credit', 'subscribe for free', 'super deal', 'supplies are limited',
|
||||
'take action', 'tens of thousands', 'the best rates',
|
||||
'time limited', 'trial offer', 'ultimate',
|
||||
'unbeatable', 'unbelievable', 'urgent', 'valued customer',
|
||||
'very cheap', 'want to make', 'while supplies last', 'winner',
|
||||
'work at home', 'work from home', 'you are a winner', 'you have been selected',
|
||||
'you have been chosen', 'you won', 'you\'ve won', 'your income',
|
||||
];
|
||||
|
||||
interface SpamCheck {
|
||||
type: 'error' | 'warning' | 'pass';
|
||||
label: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
interface SpamResult {
|
||||
score: number;
|
||||
checks: SpamCheck[];
|
||||
triggeredWords: string[];
|
||||
}
|
||||
|
||||
function analyzeSpam(subject: string, body: string): SpamResult {
|
||||
const checks: SpamCheck[] = [];
|
||||
const triggeredWords: string[] = [];
|
||||
let deductions = 0;
|
||||
|
||||
const fullText = `${subject} ${body}`.toLowerCase();
|
||||
|
||||
// Check for spam trigger words
|
||||
const foundWords = SPAM_TRIGGER_WORDS.filter(word => fullText.includes(word));
|
||||
if (foundWords.length === 0) {
|
||||
checks.push({type: 'pass', label: 'No spam trigger words', detail: 'Your content does not contain common spam trigger phrases.'});
|
||||
} else if (foundWords.length <= 2) {
|
||||
checks.push({type: 'warning', label: `${foundWords.length} spam trigger word${foundWords.length > 1 ? 's' : ''} found`, detail: `Found: ${foundWords.slice(0, 5).join(', ')}. Consider rephrasing these.`});
|
||||
deductions += foundWords.length * 8;
|
||||
triggeredWords.push(...foundWords.slice(0, 5));
|
||||
} else {
|
||||
checks.push({type: 'error', label: `${foundWords.length} spam trigger words found`, detail: `Found: ${foundWords.slice(0, 6).join(', ')}${foundWords.length > 6 ? ` and ${foundWords.length - 6} more` : ''}. Rewrite to use natural language.`});
|
||||
deductions += Math.min(foundWords.length * 8, 45);
|
||||
triggeredWords.push(...foundWords.slice(0, 6));
|
||||
}
|
||||
|
||||
// Check subject line length
|
||||
if (subject.length === 0) {
|
||||
checks.push({type: 'error', label: 'Empty subject line', detail: 'A subject line is required. Missing subjects trigger spam filters.'});
|
||||
deductions += 20;
|
||||
} else if (subject.length < 10) {
|
||||
checks.push({type: 'warning', label: 'Subject line too short', detail: 'Very short subjects may appear suspicious. Aim for 30–50 characters.'});
|
||||
deductions += 5;
|
||||
} else if (subject.length > 70) {
|
||||
checks.push({type: 'warning', label: 'Subject line too long', detail: `${subject.length} characters. Subject lines over 70 characters are truncated and may reduce opens.`});
|
||||
deductions += 5;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'Good subject line length', detail: `${subject.length} characters — within the recommended 30–70 character range.`});
|
||||
}
|
||||
|
||||
// Check for excessive capitalization in subject
|
||||
const subjectWords = subject.split(' ').filter(w => w.length > 2);
|
||||
const capsWords = subjectWords.filter(w => w === w.toUpperCase() && /[A-Z]/.test(w));
|
||||
if (capsWords.length > 2) {
|
||||
checks.push({type: 'error', label: 'Excessive capitalization in subject', detail: `"${capsWords.join('", "')}" — all-caps words look like shouting and trigger spam filters.`});
|
||||
deductions += 15;
|
||||
} else if (capsWords.length > 0) {
|
||||
checks.push({type: 'warning', label: 'Capitalized words in subject', detail: `"${capsWords.join('", "')}" — use sentence case for a more professional appearance.`});
|
||||
deductions += 5;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'No excessive capitalization', detail: 'Subject line uses normal capitalization.'});
|
||||
}
|
||||
|
||||
// Check for excessive exclamation marks in subject
|
||||
const exclamationCount = (subject.match(/!/g) || []).length;
|
||||
if (exclamationCount > 1) {
|
||||
checks.push({type: 'error', label: 'Multiple exclamation marks in subject', detail: `${exclamationCount} exclamation marks found. Multiple "!" are a strong spam signal.`});
|
||||
deductions += 10;
|
||||
} else if (exclamationCount === 1) {
|
||||
checks.push({type: 'warning', label: 'Exclamation mark in subject', detail: 'A single exclamation mark is borderline. Prefer natural punctuation.'});
|
||||
deductions += 3;
|
||||
} else {
|
||||
checks.push({type: 'pass', label: 'No excessive punctuation', detail: 'No exclamation marks in the subject line.'});
|
||||
}
|
||||
|
||||
// Check for $ in subject
|
||||
if (subject.includes('$')) {
|
||||
checks.push({type: 'warning', label: 'Dollar sign in subject', detail: 'The $ symbol in subject lines is a common spam trigger. Spell out "dollars" or reframe the message.'});
|
||||
deductions += 8;
|
||||
}
|
||||
|
||||
// Check for % in subject (common in discount spam)
|
||||
if (subject.includes('%')) {
|
||||
checks.push({type: 'warning', label: 'Percentage in subject', detail: 'Percentage symbols in subject lines are frequently associated with promotional spam.'});
|
||||
deductions += 5;
|
||||
}
|
||||
|
||||
// Check body length
|
||||
if (body.length > 0 && body.length < 50) {
|
||||
checks.push({type: 'warning', label: 'Very short email body', detail: 'Very short bodies with mostly links raise spam flags. Add more context and value.'});
|
||||
deductions += 5;
|
||||
} else if (body.length >= 50) {
|
||||
checks.push({type: 'pass', label: 'Email body length acceptable', detail: 'Body content length looks reasonable for a legitimate email.'});
|
||||
}
|
||||
|
||||
const score = Math.max(0, 100 - deductions);
|
||||
return {score, checks, triggeredWords};
|
||||
}
|
||||
|
||||
function ScoreBadge({score}: {score: number}) {
|
||||
const color = score >= 80 ? 'text-green-700 bg-green-50 border-green-200' : score >= 60 ? 'text-amber-700 bg-amber-50 border-amber-200' : 'text-red-700 bg-red-50 border-red-200';
|
||||
const label = score >= 80 ? 'Likely Clean' : score >= 60 ? 'Risky' : 'High Spam Risk';
|
||||
return (
|
||||
<div className={`inline-flex flex-col items-center rounded-2xl border-2 px-8 py-6 ${color}`}>
|
||||
<span className={'text-5xl font-extrabold'}>{score}</span>
|
||||
<span className={'mt-1 text-sm font-semibold uppercase tracking-widest'}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const faqs: FAQ[] = [
|
||||
{
|
||||
question: 'What is a spam checker?',
|
||||
answer:
|
||||
'A spam checker analyzes email subject lines and content for characteristics that spam filters use to block or flag emails. It looks for spam trigger words, excessive capitalization, suspicious punctuation, and other signals that inbox providers associate with spam. Running your email through a spam checker before sending helps improve deliverability.',
|
||||
},
|
||||
{
|
||||
question: 'What causes emails to go to spam?',
|
||||
answer:
|
||||
'Emails land in spam for many reasons: spam trigger words in the subject or body, poor sender reputation, missing SPF/DKIM/DMARC authentication, sending to purchased or unengaged lists with high bounce rates, excessive links or images, HTML-to-text ratio issues, or being reported as spam by previous recipients. Authentication issues and sender reputation are the most common causes.',
|
||||
},
|
||||
{
|
||||
question: 'How do I check if my email will be spam?',
|
||||
answer:
|
||||
'Use a spam checker tool like this one to analyze your subject line and content. Also check your email authentication setup—verify that SPF, DKIM, and DMARC are correctly configured for your domain. Send test emails to spam testing services, and monitor your deliverability metrics (bounce rate, spam complaint rate) over time.',
|
||||
},
|
||||
{
|
||||
question: 'What are spam trigger words?',
|
||||
answer:
|
||||
'Spam trigger words are phrases commonly associated with spam emails. They include words like "free", "guaranteed", "act now", "click here", "make money", "winner", "prize", "urgent", and many others. Spam filters assign point values to these words, and emails exceeding a threshold score are flagged or blocked. Avoiding these words and using natural language improves deliverability.',
|
||||
},
|
||||
{
|
||||
question: 'Is email authentication important for avoiding spam?',
|
||||
answer:
|
||||
'Yes—email authentication (SPF, DKIM, DMARC) is critical. Without proper authentication, receiving mail servers have no way to verify your email is legitimate, making it more likely to be treated as spam. Since 2024, Gmail and Yahoo require DMARC authentication for bulk senders. Properly authenticated emails with a clean sender reputation reach the inbox far more reliably than unauthenticated emails.',
|
||||
},
|
||||
];
|
||||
|
||||
export default function SpamCheckerPage() {
|
||||
const [subject, setSubject] = useState('');
|
||||
const [bodyText, setBodyText] = useState('');
|
||||
const [result, setResult] = useState<SpamResult | null>(null);
|
||||
|
||||
const handleCheck = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setResult(analyzeSpam(subject, bodyText));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Email Spam Checker | Free Subject Line & Content Spam Test | Plunk"
|
||||
description="Free spam checker tool. Test your email subject line and content for spam trigger words, capitalization issues, and other factors that cause emails to land in spam."
|
||||
canonical="https://www.useplunk.com/tools/spam-checker"
|
||||
openGraph={{
|
||||
title: 'Email Spam Checker | Free Subject Line & Content Spam Test | Plunk',
|
||||
description: 'Free spam checker. Test your email subject line and content for factors that cause emails to land in spam folders.',
|
||||
url: 'https://www.useplunk.com/tools/spam-checker',
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Spam+Checker&tag=Tool', alt: 'Plunk Spam Checker', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
<Navbar />
|
||||
|
||||
<div className={`${display.variable} ${body.variable} ${mono.variable}`}>
|
||||
<main className={'text-neutral-800'}>
|
||||
{/* ========== HERO ========== */}
|
||||
<section className={'relative overflow-hidden'}>
|
||||
<div
|
||||
aria-hidden
|
||||
className={
|
||||
'absolute inset-0 -z-10 h-full w-full bg-white bg-[linear-gradient(to_right,#eeeeee_1px,transparent_1px),linear-gradient(to_bottom,#eeeeee_1px,transparent_1px)] bg-[size:6rem_6rem] [mask-image:radial-gradient(ellipse_70%_60%_at_50%_30%,#000_40%,transparent_95%)]'
|
||||
}
|
||||
/>
|
||||
|
||||
<div className={'mx-auto max-w-[88rem] px-6 pb-24 pt-20 sm:px-10 sm:pt-28 lg:pb-36'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 8}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={
|
||||
'mb-16 flex items-center justify-between border-t border-neutral-900/90 pt-4 text-[11px] uppercase tracking-[0.18em] text-neutral-700 sm:mb-24'
|
||||
}
|
||||
>
|
||||
<span className={'font-medium text-neutral-900'}>§ T-03 — Tool</span>
|
||||
<Link href="/tools" className={'text-neutral-500 transition hover:text-neutral-900'}>
|
||||
← All tools
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-5xl text-center'}
|
||||
>
|
||||
<h1
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={
|
||||
'text-[clamp(2.5rem,7vw,6.5rem)] font-extrabold leading-[0.92] tracking-[-0.04em] text-neutral-900'
|
||||
}
|
||||
>
|
||||
Email spam
|
||||
<br />
|
||||
checker
|
||||
</h1>
|
||||
<p className={'mx-auto mt-8 max-w-2xl text-lg leading-relaxed text-neutral-600 sm:text-xl'}>
|
||||
Test your subject line and email content for spam trigger words, punctuation issues, and other
|
||||
factors that cause emails to land in spam folders.
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== CHECKER TOOL ========== */}
|
||||
<section className={'mx-auto max-w-[88rem] px-6 py-16 sm:px-10 sm:py-20'}>
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 20}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.7, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mx-auto max-w-2xl'}
|
||||
>
|
||||
<div className={'overflow-hidden rounded-[20px] border border-neutral-200 bg-white'}>
|
||||
<div className={'border-b border-neutral-200 px-8 py-5'}>
|
||||
<div className={'flex items-center gap-3'}>
|
||||
<ShieldAlert className={'h-4 w-4 text-neutral-500'} strokeWidth={1.5} />
|
||||
<span
|
||||
style={{fontFamily: 'var(--font-mono)'}}
|
||||
className={'text-[11px] uppercase tracking-[0.18em] text-neutral-500'}
|
||||
>
|
||||
Analyze email content
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleCheck} className={'space-y-4 p-8'}>
|
||||
<div>
|
||||
<label htmlFor="subject" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Subject line <span className={'text-red-500'}>*</span>
|
||||
</label>
|
||||
<Input
|
||||
id="subject"
|
||||
type="text"
|
||||
value={subject}
|
||||
onChange={e => setSubject(e.target.value)}
|
||||
placeholder="Your email subject line"
|
||||
required
|
||||
className={'w-full'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="body" className={'mb-2 block text-sm font-medium text-neutral-900'}>
|
||||
Email body text <span className={'text-neutral-400 text-xs font-normal'}>(optional)</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="body"
|
||||
value={bodyText}
|
||||
onChange={e => setBodyText(e.target.value)}
|
||||
placeholder="Paste your email body text here (plain text, no HTML needed)..."
|
||||
rows={6}
|
||||
className={'w-full rounded-lg border border-neutral-200 bg-white px-4 py-3 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-neutral-900 focus:outline-none focus:ring-1 focus:ring-neutral-900 resize-none'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className={'w-full gap-2'}>
|
||||
<ShieldAlert className={'h-4 w-4'} />
|
||||
Check for spam signals
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
animate={{opacity: 1, y: 0}}
|
||||
transition={{duration: 0.5, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'mt-6 space-y-4'}
|
||||
>
|
||||
{/* Score */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<div className={'flex flex-col items-center gap-4 text-center'}>
|
||||
<ScoreBadge score={result.score} />
|
||||
<p className={'max-w-sm text-sm text-neutral-600'}>
|
||||
{result.score >= 80
|
||||
? 'Your email looks clean. No major spam signals detected.'
|
||||
: result.score >= 60
|
||||
? 'Some spam signals detected. Review the issues below and consider rewriting flagged sections.'
|
||||
: 'High spam risk. Address the issues below before sending to protect deliverability.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues */}
|
||||
<div className={'rounded-[20px] border border-neutral-200 bg-white p-8'}>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'mb-6 text-lg font-bold text-neutral-900'}
|
||||
>
|
||||
Detailed analysis
|
||||
</h3>
|
||||
<ul className={'space-y-3'}>
|
||||
{result.checks.map((check, i) => (
|
||||
<li key={i} className={'flex items-start gap-3'}>
|
||||
{check.type === 'pass' ? (
|
||||
<CheckCircle className={'mt-0.5 h-5 w-5 shrink-0 text-green-600'} />
|
||||
) : check.type === 'warning' ? (
|
||||
<AlertTriangle className={'mt-0.5 h-5 w-5 shrink-0 text-amber-500'} />
|
||||
) : (
|
||||
<XCircle className={'mt-0.5 h-5 w-5 shrink-0 text-red-500'} />
|
||||
)}
|
||||
<div>
|
||||
<p className={'text-sm font-medium text-neutral-900'}>{check.label}</p>
|
||||
<p className={'mt-0.5 text-xs text-neutral-500'}>{check.detail}</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{result.triggeredWords.length > 0 && (
|
||||
<div className={'rounded-[20px] border border-red-100 bg-red-50 p-6'}>
|
||||
<h3 className={'mb-3 text-sm font-semibold text-red-900'}>Flagged phrases</h3>
|
||||
<div className={'flex flex-wrap gap-2'}>
|
||||
{result.triggeredWords.map((word, i) => (
|
||||
<span key={i} className={'rounded-full bg-red-100 px-3 py-1 text-xs font-medium text-red-800'}>
|
||||
{word}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<p className={'mt-3 text-xs text-red-700'}>
|
||||
Replace these with natural, conversational language to improve deliverability.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</motion.div>
|
||||
</section>
|
||||
|
||||
{/* ========== WHY EMAILS GO TO SPAM ========== */}
|
||||
<section className={'border-t border-neutral-200 bg-neutral-50/60'}>
|
||||
<div className={'mx-auto max-w-[88rem] px-6 py-28 sm:px-10 sm:py-36'}>
|
||||
<SectionHeader
|
||||
number={'01'}
|
||||
label={'Spam factors'}
|
||||
title={'Why emails go to spam.'}
|
||||
subtitle={'Spam filters look at dozens of signals. Content is just one part of the picture.'}
|
||||
/>
|
||||
|
||||
<div className={'mt-20 grid gap-6 sm:grid-cols-2 lg:grid-cols-3'}>
|
||||
{[
|
||||
{
|
||||
title: 'Spam trigger words',
|
||||
body: 'Words like "free", "guaranteed", "click here", and "act now" score heavily with spam filters. Even one or two can tip the scales.',
|
||||
},
|
||||
{
|
||||
title: 'Missing authentication',
|
||||
body: 'Without SPF, DKIM, and DMARC configured, receiving servers cannot verify your email is legitimate. Authentication failures are the leading cause of spam classification.',
|
||||
},
|
||||
{
|
||||
title: 'Poor sender reputation',
|
||||
body: 'Email providers track bounce rates, spam complaints, and engagement over time. A low sender reputation score means even clean emails land in spam.',
|
||||
},
|
||||
{
|
||||
title: 'Excessive capitalization',
|
||||
body: 'ALL CAPS SUBJECT LINES and words in all caps look like shouting and are heavily penalized by spam filters. Use sentence case.',
|
||||
},
|
||||
{
|
||||
title: 'High bounce & complaint rates',
|
||||
body: 'If many recipients report your emails as spam or addresses bounce, providers penalize future emails from your domain.',
|
||||
},
|
||||
{
|
||||
title: 'Purchased email lists',
|
||||
body: 'Bought lists have unverified addresses, old data, and spam traps. Sending to them destroys your sender reputation quickly.',
|
||||
},
|
||||
].map((item, i) => (
|
||||
<motion.div
|
||||
key={item.title}
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.5, delay: i * 0.05, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={
|
||||
'flex flex-col gap-4 rounded-[20px] border border-neutral-200 bg-white p-8'
|
||||
}
|
||||
>
|
||||
<h3
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-xl font-bold tracking-[-0.02em] text-neutral-900'}
|
||||
>
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className={'text-sm leading-relaxed text-neutral-600'}>{item.body}</p>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ========== 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-32 sm:px-10 sm:py-40'}>
|
||||
<div className={'flex flex-col items-start gap-12 lg:flex-row lg:items-end lg:justify-between'}>
|
||||
<motion.h2
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, ease: [0.22, 1, 0.36, 1]}}
|
||||
style={{fontFamily: 'var(--font-display)'}}
|
||||
className={'text-[clamp(2.5rem,7vw,6rem)] font-extrabold leading-[0.95] tracking-[-0.035em]'}
|
||||
>
|
||||
Send emails that reach the inbox.
|
||||
</motion.h2>
|
||||
|
||||
<motion.div
|
||||
initial={{opacity: 0, y: 16}}
|
||||
whileInView={{opacity: 1, y: 0}}
|
||||
viewport={{once: true}}
|
||||
transition={{duration: 0.9, delay: 0.15, ease: [0.22, 1, 0.36, 1]}}
|
||||
className={'flex max-w-md flex-col gap-6'}
|
||||
>
|
||||
<p className={'text-base text-neutral-300 sm:text-lg'}>
|
||||
Plunk automatically handles SPF, DKIM, and DMARC authentication — the most critical deliverability
|
||||
factor. Start free, no credit card required.
|
||||
</p>
|
||||
<div className={'flex flex-wrap gap-3'}>
|
||||
<motion.a
|
||||
whileHover={{scale: 1.015}}
|
||||
whileTap={{scale: 0.985}}
|
||||
href={`${DASHBOARD_URI}/auth/signup`}
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full bg-white px-7 py-3.5 text-sm font-semibold text-neutral-900 transition hover:bg-neutral-100'
|
||||
}
|
||||
>
|
||||
Start with Plunk
|
||||
<ArrowRight className={'h-4 w-4'} />
|
||||
</motion.a>
|
||||
<Link
|
||||
href="/guides/email-deliverability"
|
||||
className={
|
||||
'inline-flex items-center gap-2 rounded-full border border-neutral-700 px-7 py-3.5 text-sm font-semibold text-white transition hover:border-white'
|
||||
}
|
||||
>
|
||||
Deliverability guide
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<FAQSection faqs={faqs} schemaId="faq-spam-checker" />
|
||||
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -58,14 +58,14 @@ export default function VerifyEmailPage() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Free Email Verification Tool | Check Email Validity | Plunk"
|
||||
description="Verify email addresses instantly. Check for typos, disposable domains, MX records, and more. Free email validation tool with detailed results."
|
||||
title="Email Validator | Free Email Address Verification Tool | Plunk"
|
||||
description="Free email validator tool. Check email addresses for validity, typos, disposable domains, and MX record configuration. Verify any email instantly."
|
||||
canonical="https://www.useplunk.com/tools/verify-email"
|
||||
openGraph={{
|
||||
title: 'Free Email Verification Tool | Check Email Validity | Plunk',
|
||||
description: 'Verify email addresses instantly. Check for typos, disposable domains, MX records, and more.',
|
||||
title: 'Email Validator | Free Email Address Verification Tool | Plunk',
|
||||
description: 'Free email validator. Check email addresses for validity, typos, disposable domains, and MX records. Verify any email instantly.',
|
||||
url: 'https://www.useplunk.com/tools/verify-email',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk Email Verification Tool'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Free+Email+Address+Validator&tag=Tool', alt: 'Plunk Email Validator Tool', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function ActiveCampaignComparison() {
|
||||
description:
|
||||
"Powerful email automation without ActiveCampaign's complexity and cost. Open-source, developer-friendly, pay-as-you-go pricing.",
|
||||
url: 'https://www.useplunk.com/vs/activecampaign',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ActiveCampaign'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=ActiveCampaign+Alternative%3A+Open-Source+%26+Affordable&tag=Comparison', alt: 'Plunk vs ActiveCampaign', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function BentoComparison() {
|
||||
description:
|
||||
'Open-source alternative to Bento. API-first email platform without CRM bloat. Self-hostable, pay-as-you-go pricing.',
|
||||
url: 'https://www.useplunk.com/vs/bento',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Bento'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Bento+Alternative%3A+Developer-First+Email+Platform&tag=Comparison', alt: 'Plunk vs Bento', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ export default function BrevoComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk"
|
||||
description="Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting. No contact limits."
|
||||
description="Done with Brevo's contact-based pricing? Plunk is a developer-first, open-source alternative — pay per email not per contact, self-hostable, no contact limits."
|
||||
canonical="https://www.useplunk.com/vs/brevo"
|
||||
openGraph={{
|
||||
title: 'Brevo (Sendinblue) Alternative: Developer-First & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers a simpler, developer-focused alternative to Brevo with pay-as-you-go pricing, open-source code, and self-hosting.',
|
||||
"Done with Brevo's contact-based pricing? Plunk is open-source, self-hostable, and charges per email not per contact.",
|
||||
url: 'https://www.useplunk.com/vs/brevo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Brevo'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Brevo+Alternative%3A+Developer-First+%26+Open+Source&tag=Comparison', alt: 'Plunk vs Brevo', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function ConvertkitComparison() {
|
||||
description:
|
||||
'Developer-first alternative to ConvertKit. Pay per email instead of per subscriber. Open-source, self-hostable, with powerful automation.',
|
||||
url: 'https://www.useplunk.com/vs/convertkit',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs ConvertKit'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=ConvertKit+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs ConvertKit', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,14 +52,14 @@ export default function CustomerioComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Customer.io Alternative: Open-Source & Developer-Focused | Plunk"
|
||||
description="Compare Plunk and Customer.io. Customer.io is powerful but complex. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing."
|
||||
description="Frustrated with Customer.io's complexity and cost? Plunk is an open-source alternative with simple workflow automation, transparent pay-as-you-go pricing, and no CRM overhead."
|
||||
canonical="https://www.useplunk.com/vs/customerio"
|
||||
openGraph={{
|
||||
title: 'Customer.io Alternative: Open-Source & Developer-Focused | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Customer.io. Plunk is open-source, developer-friendly, with simple workflows and transparent pricing.',
|
||||
"Frustrated with Customer.io's complexity and cost? Plunk is open-source with simple workflow automation and transparent pay-as-you-go pricing.",
|
||||
url: 'https://www.useplunk.com/vs/customerio',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Customer.io'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Customer.io+Alternative%3A+Open-Source+%26+Developer-Focused&tag=Comparison', alt: 'Plunk vs Customer.io', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -91,15 +91,15 @@ export default function CompetitorsIndex() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Email Competitors | Compare Email Platforms"
|
||||
description="Compare Plunk with Postmark, SendGrid, Mailgun, Mailchimp, ConvertKit, ActiveCampaign, Klaviyo, MailerLite, and more. See why Plunk offers transactional emails plus marketing features in one open-source platform."
|
||||
title="Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk"
|
||||
description="Looking for a Resend, Mailgun, or SendGrid alternative? Plunk is open-source, self-hostable, and includes transactional emails, marketing campaigns, and workflow automation — at $0.001/email."
|
||||
canonical="https://www.useplunk.com/vs"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Email Competitors | Compare Email Platforms',
|
||||
title: 'Best Resend, Mailgun & SendGrid Alternatives | Open-Source Email | Plunk',
|
||||
description:
|
||||
'Compare Plunk with 13 leading email platforms. Transactional + marketing emails in one open-source solution.',
|
||||
'Open-source alternative to Resend, Mailgun, and SendGrid. Transactional + marketing emails in one self-hostable platform at $0.001/email.',
|
||||
url: 'https://www.useplunk.com/vs',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Competitors'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Best+Email+Platform+Alternatives&tag=Comparison', alt: 'Plunk vs Competitors', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function KlaviyoComparison() {
|
||||
description:
|
||||
"E-commerce email automation without Klaviyo's price tag. Pay-as-you-go pricing, open-source, self-hostable.",
|
||||
url: 'https://www.useplunk.com/vs/klaviyo',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Klaviyo'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Klaviyo+Alternative%3A+Affordable+E-commerce+Email&tag=Comparison', alt: 'Plunk vs Klaviyo', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ export default function LoopsComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Loops Alternative: Open Source & Self-Hostable | Plunk"
|
||||
description="Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits. Same modern features, full transparency."
|
||||
description="Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing, no contact limits, and full code transparency."
|
||||
canonical="https://www.useplunk.com/vs/loops"
|
||||
openGraph={{
|
||||
title: 'Loops Alternative: Open Source & Self-Hostable | Plunk',
|
||||
description:
|
||||
'Plunk offers an open-source alternative to Loops with pay-as-you-go pricing, self-hosting, and no contact limits.',
|
||||
'Need more control than Loops offers? Plunk is open-source and self-hostable with pay-as-you-go pricing and no contact limits.',
|
||||
url: 'https://www.useplunk.com/vs/loops',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Loops'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Loops+Alternative%3A+Open+Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs Loops', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ export default function MailchimpComparison() {
|
||||
description:
|
||||
'Plunk is Mailchimp for developers: code-first email platform with modern API. Pay per email, not per contact.',
|
||||
url: 'https://www.useplunk.com/vs/mailchimp',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailchimp'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Mailchimp+Alternative+for+Developers&tag=Comparison', alt: 'Plunk vs Mailchimp', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export default function MailerliteComparison() {
|
||||
description:
|
||||
'The truly developer-first MailerLite alternative. Open-source, self-hostable, pay per email not per subscriber.',
|
||||
url: 'https://www.useplunk.com/vs/mailerlite',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs MailerLite'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=MailerLite+Alternative%3A+Open-Source+%26+Self-Hostable&tag=Comparison', alt: 'Plunk vs MailerLite', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,14 +52,14 @@ export default function MailgunComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="Mailgun Alternative: Open-Source with Marketing & Automation | Plunk"
|
||||
description="Compare Plunk and Mailgun. Mailgun focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source."
|
||||
description="Switching from Mailgun? Plunk is an open-source alternative with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers, no lock-in."
|
||||
canonical="https://www.useplunk.com/vs/mailgun"
|
||||
openGraph={{
|
||||
title: 'Mailgun Alternative: Open-Source with Marketing & Automation | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Mailgun. Mailgun is excellent for transactional emails. Plunk adds marketing, workflows, and is open-source.',
|
||||
'Switching from Mailgun? Plunk is open-source with pay-as-you-go pricing, marketing campaigns, and workflow automation — no subscription tiers.',
|
||||
url: 'https://www.useplunk.com/vs/mailgun',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Mailgun'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Mailgun+Alternative%3A+Open-Source+with+Marketing+%26+Automation&tag=Comparison', alt: 'Plunk vs Mailgun', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -52,15 +52,15 @@ export default function PostmarkComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Postmark Alternative: Transactional + Marketing Emails | Plunk"
|
||||
description="Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation. Open-source and self-hostable."
|
||||
title="Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk"
|
||||
description="Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns, workflows, and open-source code."
|
||||
canonical="https://www.useplunk.com/vs/postmark"
|
||||
openGraph={{
|
||||
title: 'Postmark Alternative: Transactional + Marketing Emails | Plunk',
|
||||
title: 'Postmark Alternative: Pay-As-You-Go vs Fixed Pricing | Plunk',
|
||||
description:
|
||||
'Plunk offers everything Postmark has for transactional emails, plus marketing campaigns, workflows, and segmentation.',
|
||||
'Postmark starts at $15/month for 10k emails. Plunk charges $0.001/email — no subscription, no minimums. Plus marketing campaigns and open-source code.',
|
||||
url: 'https://www.useplunk.com/vs/postmark',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Postmark'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Postmark+Alternative%3A+Pay-As-You-Go+vs+Fixed+Pricing&tag=Comparison', alt: 'Plunk vs Postmark', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -51,15 +51,15 @@ export default function ResendComparison() {
|
||||
return (
|
||||
<>
|
||||
<NextSeo
|
||||
title="Plunk vs Resend: Open-Source Alternative with Marketing | Plunk"
|
||||
description="Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing campaigns, workflows, and is open-source. Choose based on your needs."
|
||||
title="Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk"
|
||||
description="Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing campaigns and workflow automation included."
|
||||
canonical="https://www.useplunk.com/vs/resend"
|
||||
openGraph={{
|
||||
title: 'Plunk vs Resend: Open-Source Alternative with Marketing',
|
||||
title: 'Open-Source Resend Alternative: Pay-As-You-Go at $0.001/Email | Plunk',
|
||||
description:
|
||||
'Compare Plunk and Resend. Resend focuses on transactional emails, while Plunk adds marketing, workflows, and is open-source.',
|
||||
'Resend uses fixed subscription pricing. Plunk is pay-as-you-go at $0.001/email — open-source, self-hostable, with marketing and automation included.',
|
||||
url: 'https://www.useplunk.com/vs/resend',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs Resend'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=Open-Source+Resend+Alternative&tag=Comparison', alt: 'Plunk vs Resend', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -56,14 +56,14 @@ export default function SendGridComparison() {
|
||||
<>
|
||||
<NextSeo
|
||||
title="SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk"
|
||||
description="Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup. Open source alternative to SendGrid."
|
||||
description="Tired of SendGrid's pricing tiers and complexity? Plunk is an open-source alternative with pay-as-you-go pricing at $0.001/email, a modern API, and no lock-in."
|
||||
canonical="https://www.useplunk.com/vs/sendgrid"
|
||||
openGraph={{
|
||||
title: 'SendGrid Alternative: Pay-As-You-Go & Open Source | Plunk',
|
||||
description:
|
||||
'Plunk offers pay-as-you-go pricing instead of monthly subscriptions, with a modern API and no complex setup.',
|
||||
"Tired of SendGrid's pricing tiers and complexity? Plunk is open-source with pay-as-you-go pricing at $0.001/email and no lock-in.",
|
||||
url: 'https://www.useplunk.com/vs/sendgrid',
|
||||
images: [{url: 'https://www.useplunk.com/assets/card.png', alt: 'Plunk vs SendGrid'}],
|
||||
images: [{url: 'https://www.useplunk.com/api/og?title=SendGrid+Alternative%3A+Open+Source+%26+Pay-As-You-Go&tag=Comparison', alt: 'Plunk vs SendGrid', width: 1200, height: 630}],
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {prisma} from './database/prisma.js';
|
||||
const API_URI = process.env.API_URI ?? 'http://localhost:3000';
|
||||
const SMTP_DOMAIN = process.env.SMTP_DOMAIN ?? '';
|
||||
const MAX_RECIPIENTS = parseInt(process.env.MAX_RECIPIENTS ?? '5', 10);
|
||||
const MAX_ATTACHMENT_SIZE_MB = parseInt(process.env.MAX_ATTACHMENT_SIZE_MB ?? '10', 10);
|
||||
const PORT_SECURE = parseInt(process.env.PORT_SECURE ?? '465', 10);
|
||||
const PORT_SUBMISSION = parseInt(process.env.PORT_SUBMISSION ?? '587', 10);
|
||||
const CERT_PATH = process.env.CERT_PATH ?? '/certs';
|
||||
@@ -441,7 +442,7 @@ function createSMTPServer(options: {secure: boolean; port: number; key?: Buffer;
|
||||
onRcptTo: handleRcptTo,
|
||||
onData: handleData,
|
||||
banner: 'Plunk SMTP Relay',
|
||||
size: 10 * 1024 * 1024, // 10MB max message size
|
||||
size: MAX_ATTACHMENT_SIZE_MB * 1024 * 1024,
|
||||
authOptional: false, // Require authentication
|
||||
};
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1080 1080">
|
||||
<style>path { fill: #000; } @media (prefers-color-scheme: dark) { path { fill: #fff; } }</style>
|
||||
<path d="M955 296.539C955 357.181 939.314 412.83 907.942 463.484C876.57 514.138 830.938 555.517 771.046 587.622C711.155 619.727 639.855 638.633 557.147 644.34L511.158 902.248C493.333 1001.42 444.849 1051 365.707 1051C322.214 1051 281.929 1038.16 244.853 1012.47C208.49 986.791 179.257 947.551 157.154 894.757C135.051 841.963 124 777.04 124 699.988C124 555.161 147.172 432.449 193.517 331.854C240.575 230.546 303.319 154.922 381.749 104.981C460.892 54.327 547.878 29 642.707 29C709.728 29 766.412 40.7717 812.757 64.3152C859.815 87.8586 895.108 119.963 918.637 160.629C942.879 200.582 955 245.885 955 296.539ZM576.398 534.114C722.562 515.565 795.645 439.584 795.645 306.171C795.645 259.084 779.959 220.915 748.587 191.664C717.928 161.699 670.157 146.717 605.274 146.717C531.835 146.717 467.665 169.904 412.764 216.278C358.577 262.651 316.51 327.217 286.564 409.976C257.331 492.021 242.714 585.838 242.714 691.427C242.714 735.66 246.992 774.9 255.548 809.145C264.817 843.39 276.225 870.143 289.772 889.406C304.032 907.956 317.579 917.23 330.413 917.23C348.238 917.23 361.785 892.617 371.054 843.39L406.347 641.13L405.633 646.299L411.708 611.502L416.534 583.854L420.128 561.451L424.529 534.114C433.798 478.466 446.988 403.912 464.1 310.451C468.378 286.194 478.004 269.072 492.977 259.084C508.663 248.382 526.844 243.031 547.521 243.031C571.05 243.031 587.806 247.669 597.788 256.943C608.483 265.505 613.83 279.417 613.83 298.68C613.83 310.095 613.117 319.369 611.691 326.504C597.908 407.581 590.181 453.037 576.398 534.114Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -1,9 +1,8 @@
|
||||
import {Button} from '@plunk/ui';
|
||||
import {Button, EmptyState, IconSpinner} from '@plunk/ui';
|
||||
import type {Activity, CursorPaginatedResponse} from '@plunk/types';
|
||||
import {network} from '../lib/network';
|
||||
import {ActivityItem} from './ActivityItem';
|
||||
import {EmptyState} from './EmptyState';
|
||||
import {Activity as ActivityIcon, Loader2} from 'lucide-react';
|
||||
import {Activity as ActivityIcon} from 'lucide-react';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
export interface ActivityFeedProps {
|
||||
@@ -169,7 +168,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-neutral-400" />
|
||||
<IconSpinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,7 +214,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
<Button onClick={loadMore} variant="outline" disabled={isLoadingMore}>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
<IconSpinner size="sm" className="mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
@@ -242,7 +241,7 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
|
||||
<div className="space-y-4">
|
||||
{upcomingActivities.map((activity, index) => (
|
||||
<div key={`${activity.id}-${index}`}>
|
||||
<ActivityItem activity={activity} isUpcoming={true} />
|
||||
<ActivityItem activity={activity} status="upcoming" />
|
||||
{index < upcomingActivities.length - 1 && <div className="border-t border-neutral-100 my-4" />}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -110,7 +110,7 @@ function isEmailActivity(type: string): boolean {
|
||||
|
||||
interface ActivityItemProps {
|
||||
activity: Activity;
|
||||
isUpcoming?: boolean;
|
||||
status?: 'upcoming' | 'completed';
|
||||
}
|
||||
|
||||
interface ActivityConfig {
|
||||
@@ -133,8 +133,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'event.triggered':
|
||||
return {
|
||||
icon: Zap,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.eventName === 'string' ? metadata.eventName : undefined) || 'Event triggered',
|
||||
description: undefined,
|
||||
badge: {
|
||||
@@ -150,8 +150,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.sent':
|
||||
return {
|
||||
icon: Send,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email sent',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -169,8 +169,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.delivered':
|
||||
return {
|
||||
icon: CheckCircle,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email delivered',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -186,8 +186,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.received':
|
||||
return {
|
||||
icon: Inbox,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-100',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email received',
|
||||
description: typeof metadata.from === 'string' ? `From: ${metadata.from}` : 'Inbound email',
|
||||
badge: {
|
||||
@@ -199,8 +199,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.opened':
|
||||
return {
|
||||
icon: Eye,
|
||||
color: 'text-purple-600',
|
||||
bgColor: 'bg-purple-100',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email opened',
|
||||
description:
|
||||
typeof metadata.totalOpens === 'number' && metadata.totalOpens > 1
|
||||
@@ -219,8 +219,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.clicked':
|
||||
return {
|
||||
icon: MousePointerClick,
|
||||
color: 'text-orange-600',
|
||||
bgColor: 'bg-orange-100',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email clicked',
|
||||
description:
|
||||
typeof metadata.totalClicks === 'number' && metadata.totalClicks > 1
|
||||
@@ -239,8 +239,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.bounced':
|
||||
return {
|
||||
icon: XCircle,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Email bounced',
|
||||
description: (typeof metadata.error === 'string' ? metadata.error : undefined) || 'Email failed to deliver',
|
||||
badge: {
|
||||
@@ -252,8 +252,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'email.complaint':
|
||||
return {
|
||||
icon: ShieldAlert,
|
||||
color: 'text-red-600',
|
||||
bgColor: 'bg-red-100',
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
title: (typeof metadata.subject === 'string' ? metadata.subject : undefined) || 'Spam complaint',
|
||||
description: metadata.campaignName
|
||||
? `Campaign: ${String(metadata.campaignName)}`
|
||||
@@ -269,8 +269,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.started':
|
||||
return {
|
||||
icon: Workflow,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-100',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow started',
|
||||
description: `Status: ${String(metadata.status || 'unknown')}`,
|
||||
badge: {
|
||||
@@ -282,8 +282,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.completed':
|
||||
return {
|
||||
icon: CheckCheck,
|
||||
color: 'text-green-600',
|
||||
bgColor: 'bg-green-100',
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
title: (typeof metadata.workflowName === 'string' ? metadata.workflowName : undefined) || 'Workflow completed',
|
||||
description: metadata.exitReason
|
||||
? `Exit: ${String(metadata.exitReason)}`
|
||||
@@ -297,8 +297,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'campaign.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-blue-600',
|
||||
bgColor: 'bg-blue-50',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.campaignName === 'string' ? metadata.campaignName : undefined) || 'Campaign scheduled',
|
||||
description: metadata.subject
|
||||
? `${String(metadata.subject)}${metadata.totalRecipients ? ` • ${metadata.totalRecipients} recipients` : ''}`
|
||||
@@ -314,8 +314,8 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
case 'workflow.email.scheduled':
|
||||
return {
|
||||
icon: Calendar,
|
||||
color: 'text-indigo-600',
|
||||
bgColor: 'bg-indigo-50',
|
||||
color: 'text-neutral-600',
|
||||
bgColor: 'bg-neutral-100',
|
||||
title: (typeof metadata.stepName === 'string' ? metadata.stepName : undefined) || 'Workflow email scheduled',
|
||||
description: metadata.workflowName
|
||||
? `Workflow: ${String(metadata.workflowName)}${metadata.subject ? ` • ${String(metadata.subject)}` : ''}`
|
||||
@@ -342,18 +342,19 @@ function getActivityConfig(activity: Activity): ActivityConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = false}: ActivityItemProps) {
|
||||
export const ActivityItem = memo(function ActivityItem({activity, status = 'completed'}: ActivityItemProps) {
|
||||
const [showPreviewModal, setShowPreviewModal] = useState(false);
|
||||
const config = getActivityConfig(activity);
|
||||
const Icon = config.icon;
|
||||
const timestamp = new Date(activity.timestamp);
|
||||
const isUpcoming = status === 'upcoming';
|
||||
const relativeTime = isUpcoming ? getUpcomingTime(timestamp) : getRelativeTime(timestamp);
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-4 ${isUpcoming ? 'opacity-80' : ''}`}>
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'ring-2 ring-offset-2 ring-blue-200' : ''}`}
|
||||
className={`h-10 w-10 rounded-lg ${config.bgColor} flex items-center justify-center flex-shrink-0 ${isUpcoming ? 'border-2 border-dashed border-neutral-300' : ''}`}
|
||||
>
|
||||
<Icon className={`h-5 w-5 ${config.color}`} />
|
||||
</div>
|
||||
@@ -410,7 +411,7 @@ export const ActivityItem = memo(function ActivityItem({activity, isUpcoming = f
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-blue-600 font-medium' : 'text-neutral-400'}`}
|
||||
className={`text-xs flex-shrink-0 whitespace-nowrap ${isUpcoming ? 'text-neutral-700 font-medium' : 'text-neutral-400'}`}
|
||||
title={timestamp.toLocaleString()}
|
||||
>
|
||||
{relativeTime}
|
||||
|
||||
@@ -9,7 +9,6 @@ interface ApiKeyDisplayProps {
|
||||
description?: string;
|
||||
isSecret?: boolean;
|
||||
onRegenerate?: () => Promise<void>;
|
||||
showRegenerate?: boolean;
|
||||
}
|
||||
|
||||
export function ApiKeyDisplay({
|
||||
@@ -18,7 +17,6 @@ export function ApiKeyDisplay({
|
||||
description,
|
||||
isSecret = false,
|
||||
onRegenerate,
|
||||
showRegenerate = false,
|
||||
}: ApiKeyDisplayProps) {
|
||||
const [showKey, setShowKey] = useState(!isSecret);
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -101,7 +99,7 @@ export function ApiKeyDisplay({
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</Button>
|
||||
{showRegenerate && onRegenerate && (
|
||||
{onRegenerate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||
import {Alert, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
|
||||
import {AlertCircle, Coins, TrendingUp} from 'lucide-react';
|
||||
import {useBillingConsumption} from '../lib/hooks/useBillingConsumption';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
@@ -67,7 +67,9 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
|
||||
<CardDescription>View your current month email consumption and costs</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<IconSpinner size="sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -109,8 +111,8 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
|
||||
{/* Total Usage */}
|
||||
<div className="border border-neutral-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-blue-50 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-blue-600" />
|
||||
<div className="p-2 bg-neutral-100 rounded-lg">
|
||||
<TrendingUp className="h-5 w-5 text-neutral-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-neutral-500">Total Emails Sent</p>
|
||||
@@ -121,19 +123,19 @@ export function BillingConsumption({projectId, hasSubscription}: BillingConsumpt
|
||||
|
||||
{/* Account Credits */}
|
||||
{consumptionData.credits && consumptionData.credits.hasCredits && (
|
||||
<div className="border border-green-200 rounded-lg p-6 bg-green-50">
|
||||
<div className="border border-neutral-200 rounded-lg p-6">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 bg-white rounded-lg shadow-sm border border-green-200">
|
||||
<Coins className="h-5 w-5 text-green-600" />
|
||||
<div className="p-2 bg-neutral-100 rounded-lg">
|
||||
<Coins className="h-5 w-5 text-neutral-600" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Account Credits</p>
|
||||
<p className="text-2xl font-bold text-green-900">
|
||||
<p className="text-sm text-neutral-500">Account Credits</p>
|
||||
<p className="text-2xl font-bold text-neutral-900">
|
||||
{formatCurrency(consumptionData.credits.creditAmount, consumptionData.credits.currency)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-green-700 mt-2">
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
Credits will be automatically applied to your upcoming invoices
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle} from '@plunk/ui';
|
||||
import {Alert, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, IconSpinner} from '@plunk/ui';
|
||||
import {AlertCircle, Download, ExternalLink, FileText} from 'lucide-react';
|
||||
import {useBillingInvoices} from '../lib/hooks/useBillingInvoices';
|
||||
|
||||
@@ -41,7 +41,9 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
|
||||
<CardDescription>View and download your billing history</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<IconSpinner size="sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -88,16 +90,16 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
|
||||
const getStatusBadge = (status: string, paid: boolean) => {
|
||||
// Stripe invoices have status 'paid' when paid, or paid boolean is true
|
||||
if (paid || status === 'paid') {
|
||||
return <Badge variant="green">Paid</Badge>;
|
||||
return <Badge variant="success">Paid</Badge>;
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return <Badge variant="orange">Unpaid</Badge>;
|
||||
return <Badge variant="warning">Unpaid</Badge>;
|
||||
case 'draft':
|
||||
return <Badge variant="neutral">Draft</Badge>;
|
||||
case 'uncollectible':
|
||||
return <Badge variant="red">Uncollectible</Badge>;
|
||||
return <Badge variant="destructive">Uncollectible</Badge>;
|
||||
case 'void':
|
||||
return <Badge variant="neutral">Void</Badge>;
|
||||
default:
|
||||
@@ -167,7 +169,7 @@ export function BillingInvoices({projectId, hasSubscription, onManageBilling}: B
|
||||
<span className="font-medium">Total:</span>{' '}
|
||||
{formatCurrency(invoice.total, invoice.currency)}
|
||||
</p>
|
||||
<p className="text-orange-600 font-medium">
|
||||
<p className="text-amber-700 font-medium">
|
||||
<span className="font-medium">Amount Due:</span>{' '}
|
||||
{formatCurrency(invoice.amountDue, invoice.currency)}
|
||||
</p>
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
IconSpinner,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
@@ -58,13 +59,13 @@ const formatEmailCost = (emailCount: number, currency: string | null): string =>
|
||||
|
||||
interface BillingLimitsProps {
|
||||
projectId: string;
|
||||
hasSubscription: boolean;
|
||||
tier: 'free' | 'paid';
|
||||
billingEnabled: boolean;
|
||||
}
|
||||
|
||||
type LimitsFormValues = z.infer<typeof BillingLimitSchemas.update>;
|
||||
|
||||
export function BillingLimits({projectId, hasSubscription, billingEnabled}: BillingLimitsProps) {
|
||||
export function BillingLimits({projectId, tier, billingEnabled}: BillingLimitsProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
@@ -131,7 +132,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
};
|
||||
|
||||
// Free tier projects can view their usage but can't edit limits
|
||||
const canEditLimits = hasSubscription;
|
||||
const canEditLimits = tier === 'paid';
|
||||
|
||||
// If billing is not enabled, don't show the component
|
||||
if (!billingEnabled) {
|
||||
@@ -146,7 +147,9 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<CardDescription>Set monthly limits for each email category</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-neutral-500">Loading...</p>
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<IconSpinner size="sm" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -157,7 +160,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<CardHeader>
|
||||
<CardTitle>Billing Limits</CardTitle>
|
||||
<CardDescription>
|
||||
{hasSubscription
|
||||
{tier === 'paid'
|
||||
? 'Set monthly limits for each email category. Limits reset on the 1st of each month.'
|
||||
: 'Free tier projects have a total limit of 1,000 emails per month across all categories.'}
|
||||
</CardDescription>
|
||||
@@ -165,7 +168,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Free tier info banner */}
|
||||
{!hasSubscription && limitsData && (
|
||||
{tier !== 'paid' && limitsData && (
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
@@ -205,7 +208,7 @@ export function BillingLimits({projectId, hasSubscription, billingEnabled}: Bill
|
||||
{!isEditing && limitsData && (
|
||||
<div className="space-y-4">
|
||||
{/* For free tier, show total usage across all categories */}
|
||||
{!hasSubscription ? (
|
||||
{tier !== 'paid' ? (
|
||||
<UsageDisplay
|
||||
category="Total Emails (All Categories)"
|
||||
usage={limitsData.workflows}
|
||||
@@ -381,15 +384,15 @@ interface UsageDisplayProps {
|
||||
|
||||
const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: UsageDisplayProps) {
|
||||
const statusColor = useMemo(() => {
|
||||
if (usage.isBlocked) return 'text-red-600';
|
||||
if (usage.isWarning) return 'text-orange-600';
|
||||
return 'text-green-600';
|
||||
if (usage.isBlocked) return 'text-red-700';
|
||||
if (usage.isWarning) return 'text-amber-700';
|
||||
return 'text-neutral-600';
|
||||
}, [usage.isBlocked, usage.isWarning]);
|
||||
|
||||
const progressColor = useMemo(() => {
|
||||
if (usage.isBlocked) return 'bg-red-600';
|
||||
if (usage.isWarning) return 'bg-orange-500';
|
||||
return 'bg-green-600';
|
||||
if (usage.isWarning) return 'bg-amber-500';
|
||||
return 'bg-neutral-900';
|
||||
}, [usage.isBlocked, usage.isWarning]);
|
||||
|
||||
const statusIcon = useMemo(() => {
|
||||
@@ -437,10 +440,10 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
||||
<Progress value={Math.min(usage.percentage, 100)} className="h-2" indicatorClassName={progressColor} />
|
||||
|
||||
{usage.isBlocked && (
|
||||
<Alert className="mt-3 bg-red-50 border-red-200 text-red-900">
|
||||
<Alert variant="destructive" className="mt-3">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className={'text-sm'}>
|
||||
<p className="text-sm">
|
||||
<strong>Limit reached:</strong> No more {category.toLowerCase()} emails can be sent this month.
|
||||
</p>
|
||||
</div>
|
||||
@@ -448,7 +451,7 @@ const UsageDisplay = memo(function UsageDisplay({category, usage, currency}: Usa
|
||||
)}
|
||||
|
||||
{usage.isWarning && !usage.isBlocked && (
|
||||
<Alert className="mt-3 bg-orange-50 border-orange-200 text-orange-900">
|
||||
<Alert variant="warning" className="mt-3">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<div className="ml-2">
|
||||
<p className="text-sm">
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
EmptyState,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
IconSpinner,
|
||||
SelectValue,
|
||||
} from '@plunk/ui';
|
||||
import type {Campaign} from '@plunk/db';
|
||||
@@ -95,23 +97,16 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: CampaignStatus) => {
|
||||
const variants: Record<
|
||||
CampaignStatus,
|
||||
{variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className?: string}
|
||||
> = {
|
||||
DRAFT: {variant: 'secondary', label: 'Draft', className: 'bg-neutral-100 text-neutral-700'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled', className: 'bg-blue-100 text-blue-700'},
|
||||
SENDING: {variant: 'default', label: 'Sending', className: 'bg-purple-100 text-purple-700'},
|
||||
SENT: {variant: 'default', label: 'Sent', className: 'bg-green-100 text-green-700'},
|
||||
CANCELLED: {variant: 'destructive', label: 'Cancelled', className: 'bg-red-100 text-red-700'},
|
||||
const variants: Record<CampaignStatus, {variant: 'neutral' | 'default' | 'success'; label: string}> = {
|
||||
DRAFT: {variant: 'neutral', label: 'Draft'},
|
||||
SCHEDULED: {variant: 'default', label: 'Scheduled'},
|
||||
SENDING: {variant: 'default', label: 'Sending'},
|
||||
SENT: {variant: 'success', label: 'Sent'},
|
||||
CANCELLED: {variant: 'neutral', label: 'Cancelled'},
|
||||
};
|
||||
|
||||
const config = variants[status];
|
||||
return (
|
||||
<Badge variant={config.variant} className={config.className}>
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const getAudienceLabel = (campaign: Campaign) => {
|
||||
@@ -130,7 +125,7 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{step === 'configure' && (
|
||||
<Button variant="ghost" size="sm" onClick={handleBack} className="h-8 w-8 p-0">
|
||||
<Button variant="ghost" size="icon" onClick={handleBack}>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
@@ -166,33 +161,16 @@ export function CampaignSelectionDialog({open, onOpenChange, onSelectCampaign}:
|
||||
<div className="flex-1 overflow-y-auto space-y-3">
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<svg
|
||||
className="h-8 w-8 animate-spin mx-auto text-neutral-900"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="mt-2 text-sm text-neutral-500">Loading campaigns...</p>
|
||||
</div>
|
||||
<IconSpinner />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && data?.data.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Mail className="h-12 w-12 text-neutral-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-neutral-900 mb-2">No campaigns found</h3>
|
||||
<p className="text-neutral-500">
|
||||
{statusFilter !== 'ALL' ? 'Try adjusting your filter' : 'Create your first campaign to get started'}
|
||||
</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Mail}
|
||||
title="No campaigns found"
|
||||
description={statusFilter !== 'ALL' ? 'Try adjusting your filter.' : 'Create your first campaign to get started.'}
|
||||
/>
|
||||
)}
|
||||
|
||||
{data?.data.map(campaign => (
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
import type {Campaign, Contact, Segment, Template, Workflow} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse, PaginatedResponse} from '@plunk/types';
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
Kbd,
|
||||
} from '@plunk/ui';
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
BookOpen,
|
||||
Check,
|
||||
Clock,
|
||||
Copy,
|
||||
FileText,
|
||||
FolderOpen,
|
||||
Layers,
|
||||
LayoutDashboard,
|
||||
Megaphone,
|
||||
Plus,
|
||||
Settings,
|
||||
Users,
|
||||
Workflow as WorkflowIcon,
|
||||
} from 'lucide-react';
|
||||
import {useRouter} from 'next/router';
|
||||
import {useEffect, useMemo, useRef, useState} from 'react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
import {WIKI_URI} from '../lib/constants';
|
||||
import {addRecentPage, getRecentPages} from '../lib/recentPages';
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ComponentType<{className?: string}>;
|
||||
keywords: string;
|
||||
shortcut: [string, string];
|
||||
}
|
||||
|
||||
const NAV_ACTIONS: Action[] = [
|
||||
{label: 'Dashboard', href: '/', icon: LayoutDashboard, keywords: 'home overview', shortcut: ['G', 'D']},
|
||||
{label: 'Contacts', href: '/contacts', icon: Users, keywords: 'subscribers people', shortcut: ['G', 'C']},
|
||||
{label: 'Segments', href: '/segments', icon: Layers, keywords: 'groups filters', shortcut: ['G', 'S']},
|
||||
{label: 'Activity', href: '/activity', icon: Activity, keywords: 'log events', shortcut: ['G', 'L']},
|
||||
{label: 'Analytics', href: '/analytics', icon: BarChart3, keywords: 'stats metrics', shortcut: ['G', 'A']},
|
||||
{label: 'Templates', href: '/templates', icon: FileText, keywords: 'email design', shortcut: ['G', 'T']},
|
||||
{label: 'Workflows', href: '/workflows', icon: WorkflowIcon, keywords: 'automation trigger', shortcut: ['G', 'W']},
|
||||
{label: 'Campaigns', href: '/campaigns', icon: Megaphone, keywords: 'broadcast newsletter', shortcut: ['G', 'M']},
|
||||
{label: 'Settings', href: '/settings', icon: Settings, keywords: 'config account billing', shortcut: ['G', ',']},
|
||||
];
|
||||
|
||||
const CREATE_ACTIONS: Action[] = [
|
||||
{label: 'New Campaign', href: '/campaigns/create', icon: Plus, keywords: 'create broadcast', shortcut: ['N', 'C']},
|
||||
{label: 'New Template', href: '/templates/create', icon: Plus, keywords: 'create email design', shortcut: ['N', 'T']},
|
||||
{label: 'New Segment', href: '/segments/new', icon: Plus, keywords: 'create group filter', shortcut: ['N', 'S']},
|
||||
{label: 'New Workflow', href: '/workflows', icon: Plus, keywords: 'create automation trigger', shortcut: ['N', 'W']},
|
||||
];
|
||||
|
||||
// Chord map: first-key → second-key → action
|
||||
const CHORDS: Record<string, Record<string, Action>> = {};
|
||||
for (const action of [...NAV_ACTIONS, ...CREATE_ACTIONS]) {
|
||||
const [first, second] = action.shortcut;
|
||||
const f = first.toLowerCase();
|
||||
const s = second.toLowerCase();
|
||||
CHORDS[f] ??= {};
|
||||
CHORDS[f][s] = action;
|
||||
}
|
||||
|
||||
function matches(text: string, query: string): boolean {
|
||||
return text.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
function ShortcutHint({shortcut}: {shortcut: [string, string]}) {
|
||||
return (
|
||||
<span className="ml-auto flex items-center gap-0.5 shrink-0">
|
||||
<Kbd>{shortcut[0]}</Kbd>
|
||||
<Kbd>{shortcut[1]}</Kbd>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
const router = useRouter();
|
||||
const {activeProject, availableProjects, setActiveProject} = useActiveProject();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const chordKeyRef = useRef<string | null>(null);
|
||||
const chordTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const openRef = useRef(open);
|
||||
openRef.current = open;
|
||||
|
||||
const recentPages = useMemo(() => (open ? getRecentPages() : []), [open]);
|
||||
|
||||
const fireChord = (key: string): boolean => {
|
||||
if (chordKeyRef.current) {
|
||||
const action = CHORDS[chordKeyRef.current]?.[key];
|
||||
chordKeyRef.current = null;
|
||||
if (chordTimerRef.current) clearTimeout(chordTimerRef.current);
|
||||
if (action) {
|
||||
void router.push(action.href);
|
||||
addRecentPage({label: action.label, href: action.href});
|
||||
setOpen(false);
|
||||
return true;
|
||||
}
|
||||
} else if (CHORDS[key]) {
|
||||
chordKeyRef.current = key;
|
||||
chordTimerRef.current = setTimeout(() => {
|
||||
chordKeyRef.current = null;
|
||||
}, 1500);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// ⌘K — global toggle
|
||||
useEffect(() => {
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen(prev => !prev);
|
||||
return;
|
||||
}
|
||||
// Chord shortcuts when focus is not in any input (e.g. user tabbed away or clicked body)
|
||||
if (openRef.current || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
|
||||
if (fireChord(e.key.toLowerCase())) e.preventDefault();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [router]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
const timer = setTimeout(() => {
|
||||
setQuery('');
|
||||
setDebouncedQuery('');
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedQuery(query), 200);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query]);
|
||||
|
||||
const shouldSearch = open && debouncedQuery.length > 0;
|
||||
|
||||
const {data: contactsData} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||
shouldSearch ? `/contacts?search=${encodeURIComponent(debouncedQuery)}&limit=5` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
const {data: templatesData} = useSWR<PaginatedResponse<Template>>(
|
||||
shouldSearch ? `/templates?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
const {data: workflowsData} = useSWR<PaginatedResponse<Workflow>>(
|
||||
shouldSearch ? `/workflows?search=${encodeURIComponent(debouncedQuery)}&pageSize=5` : null,
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
const {data: segmentsData} = useSWR<Segment[]>(shouldSearch ? '/segments' : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const {data: campaignsData} = useSWR<PaginatedResponse<Campaign>>(shouldSearch ? '/campaigns?pageSize=20' : null, {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const contacts = contactsData?.data ?? [];
|
||||
const templates = templatesData?.data ?? [];
|
||||
const workflows = workflowsData?.data ?? [];
|
||||
const segments = (segmentsData ?? []).filter(s => matches(s.name, debouncedQuery));
|
||||
const campaigns = (campaignsData?.data ?? []).filter(
|
||||
c => matches(c.name, debouncedQuery) || matches(c.subject ?? '', debouncedQuery),
|
||||
);
|
||||
|
||||
const filteredNavActions = shouldSearch
|
||||
? NAV_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
|
||||
: NAV_ACTIONS;
|
||||
|
||||
const filteredCreateActions = shouldSearch
|
||||
? CREATE_ACTIONS.filter(a => matches(a.label, query) || matches(a.keywords, query))
|
||||
: CREATE_ACTIONS;
|
||||
|
||||
const filteredProjects = availableProjects.filter(p => matches(p.name, debouncedQuery || query));
|
||||
|
||||
const switchProject = (project: (typeof availableProjects)[number]) => {
|
||||
setActiveProject(project);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const navigate = (href: string, label: string) => {
|
||||
void router.push(href);
|
||||
addRecentPage({label, href});
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const copySecretKey = () => {
|
||||
if (!activeProject?.secret) return;
|
||||
void navigator.clipboard.writeText(activeProject.secret);
|
||||
toast.success('Secret key copied');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const copyPublicKey = () => {
|
||||
if (!activeProject?.public) return;
|
||||
void navigator.clipboard.writeText(activeProject.public);
|
||||
toast.success('Public key copied');
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const hasResults = shouldSearch
|
||||
? contacts.length > 0 ||
|
||||
campaigns.length > 0 ||
|
||||
templates.length > 0 ||
|
||||
workflows.length > 0 ||
|
||||
segments.length > 0 ||
|
||||
filteredNavActions.length > 0 ||
|
||||
filteredCreateActions.length > 0 ||
|
||||
filteredProjects.length > 0
|
||||
: true;
|
||||
|
||||
return (
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Search or jump to…" value={query} onValueChange={setQuery} />
|
||||
<CommandList className="pb-2">
|
||||
{!hasResults && <CommandEmpty>No results for “{query}”</CommandEmpty>}
|
||||
|
||||
{!shouldSearch && recentPages.length > 0 && (
|
||||
<CommandGroup heading="Recent">
|
||||
{recentPages.map(page => (
|
||||
<CommandItem
|
||||
key={page.href}
|
||||
value={`recent-${page.href}`}
|
||||
onSelect={() => navigate(page.href, page.label)}
|
||||
>
|
||||
<Clock className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{page.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{shouldSearch && contacts.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Contacts">
|
||||
{contacts.map(contact => (
|
||||
<CommandItem
|
||||
key={contact.id}
|
||||
value={`contact-${contact.id}-${contact.email}`}
|
||||
onSelect={() => navigate(`/contacts/${contact.id}`, contact.email)}
|
||||
>
|
||||
<Users className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{contact.email}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldSearch && campaigns.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Campaigns">
|
||||
{campaigns.map(campaign => (
|
||||
<CommandItem
|
||||
key={campaign.id}
|
||||
value={`campaign-${campaign.id}-${campaign.name}`}
|
||||
onSelect={() => navigate(`/campaigns/${campaign.id}`, campaign.name)}
|
||||
>
|
||||
<Megaphone className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{campaign.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldSearch && templates.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Templates">
|
||||
{templates.map(template => (
|
||||
<CommandItem
|
||||
key={template.id}
|
||||
value={`template-${template.id}-${template.name}`}
|
||||
onSelect={() => navigate(`/templates/${template.id}`, template.name)}
|
||||
>
|
||||
<FileText className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{template.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldSearch && workflows.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Workflows">
|
||||
{workflows.map(workflow => (
|
||||
<CommandItem
|
||||
key={workflow.id}
|
||||
value={`workflow-${workflow.id}-${workflow.name}`}
|
||||
onSelect={() => navigate(`/workflows/${workflow.id}`, workflow.name)}
|
||||
>
|
||||
<WorkflowIcon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{workflow.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldSearch && segments.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Segments">
|
||||
{segments.map(segment => (
|
||||
<CommandItem
|
||||
key={segment.id}
|
||||
value={`segment-${segment.id}-${segment.name}`}
|
||||
onSelect={() => navigate(`/segments/${segment.id}`, segment.name)}
|
||||
>
|
||||
<Layers className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{segment.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{availableProjects.length > 1 && filteredProjects.length > 0 && (
|
||||
<>
|
||||
<CommandGroup heading="Switch project">
|
||||
{filteredProjects.map(project => (
|
||||
<CommandItem
|
||||
key={project.id}
|
||||
value={`project-${project.id}-${project.name}`}
|
||||
onSelect={() => switchProject(project)}
|
||||
>
|
||||
<FolderOpen className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{project.name}</span>
|
||||
{project.id === activeProject?.id && (
|
||||
<Check className="ml-auto h-4 w-4 text-neutral-400 shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{filteredNavActions.length > 0 && (
|
||||
<CommandGroup heading="Go to">
|
||||
{filteredNavActions.map(action => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={action.href}
|
||||
value={`nav-${action.href}-${action.label}-${action.keywords}`}
|
||||
onSelect={() => navigate(action.href, action.label)}
|
||||
>
|
||||
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{action.label}</span>
|
||||
<ShortcutHint shortcut={action.shortcut} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{filteredCreateActions.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Create">
|
||||
{filteredCreateActions.map(action => {
|
||||
const Icon = action.icon;
|
||||
return (
|
||||
<CommandItem
|
||||
key={action.href + action.label}
|
||||
value={`create-${action.label}-${action.keywords}`}
|
||||
onSelect={() => navigate(action.href, action.label)}
|
||||
>
|
||||
<Icon className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>{action.label}</span>
|
||||
<ShortcutHint shortcut={action.shortcut} />
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(!shouldSearch || matches('api key copy code developer', query)) && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="Developer">
|
||||
{activeProject?.secret && (!shouldSearch || matches('copy secret key private developer', query)) && (
|
||||
<CommandItem value="copy-secret-key-private-developer" onSelect={copySecretKey}>
|
||||
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>Copy secret key</span>
|
||||
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
|
||||
{activeProject.secret.slice(0, 8)}…
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{activeProject?.public &&
|
||||
(!shouldSearch || matches('copy public key frontend client developer', query)) && (
|
||||
<CommandItem value="copy-public-key-frontend-client-developer" onSelect={copyPublicKey}>
|
||||
<Copy className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>Copy public key</span>
|
||||
<span className="ml-auto font-mono text-xs text-neutral-400 truncate max-w-[120px]">
|
||||
{activeProject.public.slice(0, 8)}…
|
||||
</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
{(!shouldSearch || matches('documentation docs wiki help', query)) && (
|
||||
<CommandItem
|
||||
value="open-documentation-docs-wiki-help"
|
||||
onSelect={() => {
|
||||
window.open(WIKI_URI, '_blank');
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<BookOpen className="mr-3 h-4 w-4 text-neutral-400 shrink-0" />
|
||||
<span>Documentation</span>
|
||||
</CommandItem>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-neutral-100 px-4 py-2.5">
|
||||
<span className="text-xs text-neutral-400">
|
||||
<Kbd>G</Kbd> go to · <Kbd>N</Kbd> new
|
||||
</span>
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1 text-xs text-neutral-400">
|
||||
<Kbd>↑↓</Kbd> navigate
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-neutral-400">
|
||||
<Kbd>↵</Kbd> open
|
||||
</span>
|
||||
<span className="flex items-center gap-1 text-xs text-neutral-400">
|
||||
<Kbd>esc</Kbd> close
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</CommandDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,59 @@
|
||||
import type {Contact} from '@plunk/db';
|
||||
import type {CursorPaginatedResponse} from '@plunk/types';
|
||||
import {Input, Popover, PopoverContent, PopoverTrigger} from '@plunk/ui';
|
||||
import {Check, ChevronsUpDown, MailCheck, MailX, Search, X} from 'lucide-react';
|
||||
import {Button, Input, Label, Popover, PopoverContent, PopoverTrigger, Switch} from '@plunk/ui';
|
||||
import {Check, ChevronsUpDown, ClipboardList, Loader2, MailCheck, MailX, Search, Sparkles, X} from 'lucide-react';
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import useSWR from 'swr';
|
||||
import {ContactSchemas} from '@plunk/shared';
|
||||
import {network} from '../lib/network';
|
||||
|
||||
type Mode = 'search' | 'paste';
|
||||
|
||||
interface ContactPickerProps {
|
||||
/** Currently selected emails */
|
||||
/** Currently selected emails (search mode) */
|
||||
selected: string[];
|
||||
/** Called when selection changes */
|
||||
/** Called when search-mode selection changes */
|
||||
onChange: (emails: string[]) => void;
|
||||
/** Emails already in the segment (shown as disabled) */
|
||||
/**
|
||||
* Called by paste mode to submit directly — skips the chip staging area.
|
||||
* Receives the parsed email list and whether new contacts should be subscribed.
|
||||
*/
|
||||
onAdd: (emails: string[], subscribed: boolean) => Promise<void>;
|
||||
/** Emails already in the segment (shown as disabled in search mode) */
|
||||
existing?: string[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searchable multi-select contact picker backed by the /contacts API.
|
||||
* Only fetches when the user types (safe for large contact lists).
|
||||
*/
|
||||
const CHIP_LIMIT = 8;
|
||||
|
||||
function parseEmails(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/)
|
||||
.map(s => s.trim().toLowerCase())
|
||||
.filter(s => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s));
|
||||
}
|
||||
|
||||
export function ContactPicker({
|
||||
selected,
|
||||
onChange,
|
||||
onAdd,
|
||||
existing = [],
|
||||
placeholder = 'Search contacts...',
|
||||
}: ContactPickerProps) {
|
||||
const [mode, setMode] = useState<Mode>('search');
|
||||
|
||||
// Search mode
|
||||
const [open, setOpen] = useState(false);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Paste mode
|
||||
const [pasteText, setPasteText] = useState('');
|
||||
const [subscribeNew, setSubscribeNew] = useState(true);
|
||||
const [debouncedPaste, setDebouncedPaste] = useState('');
|
||||
const pasteDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => setDebouncedSearch(search), 300);
|
||||
@@ -38,7 +62,23 @@ export function ContactPicker({
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
// Only fetch when there's a search term — avoids loading all contacts on open
|
||||
useEffect(() => {
|
||||
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
|
||||
pasteDebounceRef.current = setTimeout(() => setDebouncedPaste(pasteText), 400);
|
||||
return () => {
|
||||
if (pasteDebounceRef.current) clearTimeout(pasteDebounceRef.current);
|
||||
};
|
||||
}, [pasteText]);
|
||||
|
||||
const debouncedEmails = parseEmails(debouncedPaste).filter(e => !existing.includes(e));
|
||||
const {data: preview, isLoading: isLooking} = useSWR(
|
||||
debouncedEmails.length > 0 ? ['/contacts/lookup', ...debouncedEmails] : null,
|
||||
() => network.fetch<{found: string[]; notFound: string[]}, typeof ContactSchemas.lookup>(
|
||||
'POST', '/contacts/lookup', {emails: debouncedEmails},
|
||||
),
|
||||
{revalidateOnFocus: false},
|
||||
);
|
||||
|
||||
const {data, isLoading} = useSWR<CursorPaginatedResponse<Contact>>(
|
||||
open && debouncedSearch.length > 0 ? `/contacts?limit=20&search=${encodeURIComponent(debouncedSearch)}` : null,
|
||||
{revalidateOnFocus: false},
|
||||
@@ -54,97 +94,195 @@ export function ContactPicker({
|
||||
}
|
||||
};
|
||||
|
||||
const parsedEmails = parseEmails(pasteText);
|
||||
const newPastedEmails = parsedEmails.filter(e => !existing.includes(e));
|
||||
|
||||
const handlePasteSubmit = async () => {
|
||||
if (newPastedEmails.length === 0) return;
|
||||
await onAdd(newPastedEmails, subscribeNew);
|
||||
setPasteText('');
|
||||
};
|
||||
|
||||
const visibleChips = selected.slice(0, CHIP_LIMIT);
|
||||
const overflowCount = selected.length - CHIP_LIMIT;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus:outline-none focus:ring-2 focus:ring-neutral-900 focus:ring-offset-0 transition-colors"
|
||||
>
|
||||
<span>{placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
align="start"
|
||||
{/* Mode toggle */}
|
||||
<div className="flex gap-1 p-0.5 bg-neutral-100 rounded-md w-fit">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('search')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
mode === 'search' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
{/* Search input */}
|
||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Type an email to search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Search className="h-3.5 w-3.5" />
|
||||
Search
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode('paste')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
mode === 'paste' ? 'bg-white text-neutral-900 shadow-sm' : 'text-neutral-500 hover:text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<ClipboardList className="h-3.5 w-3.5" />
|
||||
Paste
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||
{debouncedSearch.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||
) : contacts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||
) : (
|
||||
contacts.map(contact => {
|
||||
const isSelected = selected.includes(contact.email);
|
||||
const isExisting = existing.includes(contact.email);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
disabled={isExisting}
|
||||
onClick={() => toggle(contact.email)}
|
||||
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||
>
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||
{isExisting && (
|
||||
<span className="text-xs text-neutral-400 shrink-0">already member</span>
|
||||
)}
|
||||
{isSelected && !isExisting && (
|
||||
<Check className="h-4 w-4 text-neutral-900 shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Selected chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selected.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||
>
|
||||
{email}
|
||||
{mode === 'search' ? (
|
||||
<>
|
||||
<Popover open={open} onOpenChange={v => { setOpen(v); if (!v) setSearch(''); }}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||
aria-label={`Remove ${email}`}
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className="flex h-10 w-full items-center justify-between rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-500 hover:border-neutral-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 transition-colors"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
<span>{placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 opacity-40 shrink-0" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
style={{width: 'var(--radix-popover-trigger-width)'}}
|
||||
align="start"
|
||||
>
|
||||
<div className="flex items-center border-b border-neutral-200 px-3 py-2">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 text-neutral-400" />
|
||||
<Input
|
||||
placeholder="Type an email to search..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="border-0 p-0 h-8 focus-visible:ring-0 focus-visible:ring-offset-0 text-sm"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-[240px] overflow-y-auto p-1">
|
||||
{debouncedSearch.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Type to search contacts</p>
|
||||
) : isLoading ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">Searching...</p>
|
||||
) : contacts.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-neutral-400">No contacts found</p>
|
||||
) : (
|
||||
contacts.map(contact => {
|
||||
const isSelected = selected.includes(contact.email);
|
||||
const isExisting = existing.includes(contact.email);
|
||||
return (
|
||||
<button
|
||||
key={contact.id}
|
||||
type="button"
|
||||
disabled={isExisting}
|
||||
onClick={() => toggle(contact.email)}
|
||||
className="w-full flex items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-neutral-50 disabled:opacity-40 disabled:cursor-not-allowed text-left transition-colors"
|
||||
>
|
||||
{contact.subscribed ? (
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
) : (
|
||||
<MailX className="h-4 w-4 text-red-500 shrink-0" />
|
||||
)}
|
||||
<span className="flex-1 truncate text-neutral-900">{contact.email}</span>
|
||||
{isExisting && <span className="text-xs text-neutral-400 shrink-0">already member</span>}
|
||||
{isSelected && !isExisting && <Check className="h-4 w-4 text-neutral-900 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Chips */}
|
||||
{selected.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{visibleChips.map(email => (
|
||||
<span
|
||||
key={email}
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-neutral-100 border border-neutral-200 pl-3 pr-1.5 py-1 text-sm text-neutral-800"
|
||||
>
|
||||
{email}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange(selected.filter(e => e !== email))}
|
||||
className="rounded-full p-0.5 hover:bg-neutral-300 transition-colors"
|
||||
aria-label={`Remove ${email}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
{overflowCount > 0 && (
|
||||
<span className="inline-flex items-center rounded-full bg-neutral-100 border border-neutral-200 px-3 py-1 text-sm text-neutral-500">
|
||||
+{overflowCount} more
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange([])}
|
||||
className="text-xs text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Paste mode — self-contained, submits directly via onAdd */
|
||||
<div className="space-y-3">
|
||||
<textarea
|
||||
value={pasteText}
|
||||
onChange={e => setPasteText(e.target.value)}
|
||||
placeholder={'Paste emails here, one per line or comma-separated...\n\[email protected]\[email protected], [email protected]'}
|
||||
rows={6}
|
||||
className="w-full rounded-md border border-neutral-200 bg-white px-3 py-2 text-sm text-neutral-900 placeholder:text-neutral-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 resize-none font-mono"
|
||||
/>
|
||||
|
||||
{/* Preview panel — toggle lives here, inline with the "new contacts" row */}
|
||||
{pasteText.trim() === '' ? (
|
||||
<p className="text-xs text-neutral-400">Accepts newline, comma, or semicolon-separated addresses</p>
|
||||
) : isLooking ? (
|
||||
<p className="flex items-center gap-1.5 text-xs text-neutral-400"><Loader2 className="h-3 w-3 animate-spin" /> Checking...</p>
|
||||
) : newPastedEmails.length > 0 && preview ? (
|
||||
<div className="rounded-md border border-neutral-200 divide-y divide-neutral-100 text-sm overflow-hidden">
|
||||
{preview.found.length > 0 && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 text-neutral-700">
|
||||
<MailCheck className="h-4 w-4 text-green-600 shrink-0" />
|
||||
<span><span className="font-medium">{preview.found.length}</span> existing contact{preview.found.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
)}
|
||||
{preview.notFound.length > 0 && (
|
||||
<div className="flex items-center justify-between px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 text-neutral-700">
|
||||
<Sparkles className="h-4 w-4 text-amber-500 shrink-0" />
|
||||
<span><span className="font-medium">{preview.notFound.length}</span> new — will be created</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Label htmlFor="subscribe-new" className="text-xs text-neutral-500 cursor-pointer">Subscribe</Label>
|
||||
<Switch id="subscribe-new" checked={subscribeNew} onCheckedChange={setSubscribeNew} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-neutral-400">
|
||||
{parsedEmails.length > 0 ? 'All detected emails are already in this segment' : 'No valid emails detected'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void handlePasteSubmit()}
|
||||
disabled={newPastedEmails.length === 0 || isLooking}
|
||||
className="w-full"
|
||||
>
|
||||
Add {newPastedEmails.length > 0 ? `${newPastedEmails.length} ` : ''}contact{newPastedEmails.length !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -159,9 +159,17 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
) => (
|
||||
<>
|
||||
{/* Logo */}
|
||||
<div className="h-16 flex items-center gap-2 px-6 border-b border-neutral-200">
|
||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||
<div className="h-16 flex items-center justify-between px-6 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/assets/logo.png" alt="Plunk" width={28} height={28} className="rounded" />
|
||||
<h1 className="text-xl font-bold text-neutral-900">Plunk</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', metaKey: true, bubbles: true}))}
|
||||
className="hidden lg:flex items-center gap-0.5 px-1.5 py-0.5 text-[10px] font-medium text-neutral-400 bg-neutral-100 border border-neutral-200 rounded hover:bg-neutral-200 hover:text-neutral-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<span>⌘</span><span>K</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Project Switcher */}
|
||||
@@ -169,10 +177,10 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<div className="relative" ref={projectMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleProjectMenu}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors"
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm rounded-lg hover:bg-neutral-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
<div className="h-8 w-8 rounded-lg bg-neutral-100 text-neutral-700 flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{activeProject?.name.charAt(0).toUpperCase() || 'P'}
|
||||
</div>
|
||||
<span className="font-medium text-neutral-900 truncate">{activeProject?.name || 'Select project'}</span>
|
||||
@@ -194,9 +202,9 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
setActiveProject(project);
|
||||
setShowProjectMenu(false);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="h-6 w-6 rounded-md bg-neutral-900 text-white flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
<div className="h-6 w-6 rounded-md bg-neutral-100 text-neutral-700 flex items-center justify-center text-xs font-medium flex-shrink-0">
|
||||
{project.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-neutral-900 text-left flex-1">{project.name}</span>
|
||||
@@ -209,7 +217,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<Link
|
||||
href="/projects/create"
|
||||
onClick={() => setShowProjectMenu(false)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-neutral-700 whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
<span>Create project</span>
|
||||
@@ -238,7 +246,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
isActive ? 'bg-neutral-100 text-neutral-900' : 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
@@ -258,7 +266,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
href={WIKI_URI}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900"
|
||||
className="flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<BookOpen className="h-5 w-5" />
|
||||
Documentation
|
||||
@@ -267,7 +275,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<Link
|
||||
href="/settings"
|
||||
onClick={() => setShowMobileMenu(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||
className={`flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ${
|
||||
router.pathname.startsWith('/settings')
|
||||
? 'bg-neutral-100 text-neutral-900'
|
||||
: 'text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900'
|
||||
@@ -280,7 +288,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<div className="relative" ref={userMenuRef}>
|
||||
<button
|
||||
onClick={handleToggleUserMenu}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors"
|
||||
className="w-full flex items-center gap-3 px-3 py-2 text-sm font-medium rounded-lg text-neutral-600 hover:bg-neutral-50 hover:text-neutral-900 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<div className="h-5 w-5 rounded-full bg-neutral-900 text-white flex items-center justify-center text-[10px] font-semibold flex-shrink-0">
|
||||
{user?.email?.charAt(0).toUpperCase() ?? '?'}
|
||||
@@ -299,7 +307,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-neutral-50 transition-colors text-red-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span>Log out</span>
|
||||
@@ -340,7 +348,7 @@ export function DashboardLayout({children}: DashboardLayoutProps) {
|
||||
<div className="lg:hidden h-16 bg-white border-b border-neutral-200 flex items-center px-4">
|
||||
<button
|
||||
onClick={() => setShowMobileMenu(true)}
|
||||
className="p-2 rounded-lg hover:bg-neutral-100 transition-colors"
|
||||
className="p-2 rounded-lg hover:bg-neutral-100 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-6 w-6 text-neutral-900" />
|
||||
|
||||
@@ -14,14 +14,16 @@ import {
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
EmptyState,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
IconSpinner,
|
||||
} from '@plunk/ui';
|
||||
import {AlertCircle, Loader2, Trash2} from 'lucide-react';
|
||||
import {AlertCircle, Database, Trash2, Zap} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import useSWR from 'swr';
|
||||
import {useActiveProject} from '../lib/contexts/ActiveProjectProvider';
|
||||
@@ -151,7 +153,11 @@ export function DataManagementSettings() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{customFields.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">No custom fields found</p>
|
||||
<EmptyState
|
||||
icon={Database}
|
||||
title="No custom fields"
|
||||
description="Custom fields appear here once contacts have data properties set via the API."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -196,7 +202,11 @@ export function DataManagementSettings() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{customEvents.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500">No custom events found</p>
|
||||
<EmptyState
|
||||
icon={Zap}
|
||||
title="No custom events"
|
||||
description="Custom events appear here once your contacts trigger events via the API."
|
||||
/>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -336,7 +346,7 @@ export function DataManagementSettings() {
|
||||
isDeleting || (!!selectedField && !fieldUsage?.canDelete) || (!!selectedEvent && !eventUsage?.canDelete)
|
||||
}
|
||||
>
|
||||
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{isDeleting && <IconSpinner size="sm" className="mr-2" />}
|
||||
Delete
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -11,16 +11,18 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
IconSpinner,
|
||||
Input,
|
||||
} from '@plunk/ui';
|
||||
import {AnimatePresence, motion} from 'framer-motion';
|
||||
import {Check, CheckCircle2, ChevronDown, Copy, Loader2, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
||||
import {Check, CheckCircle2, ChevronDown, Copy, Globe, RefreshCw, Trash2, XCircle} from 'lucide-react';
|
||||
import {useConfig} from '../lib/hooks/useConfig';
|
||||
import {useAddDomain, useCheckDomainVerification, useDomains, useRemoveDomain} from '../lib/hooks/useDomains';
|
||||
|
||||
@@ -321,12 +323,14 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-neutral-400" />
|
||||
<IconSpinner />
|
||||
</div>
|
||||
) : !domains || domains.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-500">
|
||||
<p>No domains added yet</p>
|
||||
</div>
|
||||
<EmptyState
|
||||
icon={Globe}
|
||||
title="No domains added"
|
||||
description="Add a custom domain above to send emails from your own address."
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{domains.map(domain => {
|
||||
@@ -357,7 +361,7 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
className="min-w-[80px]"
|
||||
>
|
||||
{checkingVerification === domain.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<IconSpinner size="sm" />
|
||||
) : (cooldownSeconds[domain.id] ?? 0) > 0 ? (
|
||||
<span className="text-xs">{cooldownSeconds[domain.id]}s</span>
|
||||
) : (
|
||||
@@ -365,13 +369,12 @@ export function DomainsSettings({projectId}: DomainsSettingsProps) {
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
variant="destructiveGhost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDomainToRemove({id: domain.id, name: domain.domain});
|
||||
setShowRemoveDialog(true);
|
||||
}}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
@@ -76,7 +76,6 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
// Fetch contacts for preview using SWR
|
||||
const {contacts} = useContacts({limit: 50});
|
||||
|
||||
// Update available variables when fields change
|
||||
useEffect(() => {
|
||||
if (availableFields.length > 0) {
|
||||
setAvailableVariables(availableFields);
|
||||
@@ -894,8 +893,8 @@ export function EmailEditor({value, onChange, placeholder, subject, from, replyT
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
0 4px 6px -1px rgba(0, 0, 0, 0.07),
|
||||
0 2px 4px -1px rgba(0, 0, 0, 0.04);
|
||||
padding: 4px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -427,7 +427,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
||||
</Button>
|
||||
{showColorPicker && (
|
||||
<div
|
||||
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-3 z-50 max-h-96 overflow-y-auto"
|
||||
className="absolute top-10 left-0 bg-white border border-neutral-200 rounded-lg shadow-md p-3 z-50 max-h-96 overflow-y-auto"
|
||||
style={{width: '280px'}}
|
||||
>
|
||||
{/* Custom color input */}
|
||||
@@ -483,7 +483,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
||||
>
|
||||
{selectedColor === color && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-white shadow-lg" />
|
||||
<div className="w-2 h-2 rounded-full bg-white shadow-sm" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -511,7 +511,7 @@ export function Toolbar({editor, onInsertVariable, onInsertImage, canUploadImage
|
||||
<Link className="h-4 w-4" />
|
||||
</Button>
|
||||
{showLinkInput && (
|
||||
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-lg p-2 z-50 min-w-max">
|
||||
<div className="absolute top-10 right-0 bg-white border border-neutral-200 rounded-lg shadow-md p-2 z-50 min-w-max">
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="url"
|
||||
|
||||
@@ -11,14 +11,9 @@ interface EmailSettingsProps {
|
||||
fromPlaceholder?: string;
|
||||
fromNamePlaceholder?: string;
|
||||
replyToPlaceholder?: string;
|
||||
showFromNameHelpText?: boolean;
|
||||
layout?: 'vertical' | 'grid';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable email settings component for from, fromName, and replyTo fields
|
||||
* Used in campaign and template forms
|
||||
*/
|
||||
export function EmailSettings({
|
||||
from,
|
||||
fromName,
|
||||
@@ -29,7 +24,6 @@ export function EmailSettings({
|
||||
fromPlaceholder = 'hello',
|
||||
fromNamePlaceholder = 'Your Company',
|
||||
replyToPlaceholder,
|
||||
showFromNameHelpText = false,
|
||||
layout = 'grid',
|
||||
}: EmailSettingsProps) {
|
||||
// Use from email's local part as the reply-to placeholder if not provided
|
||||
@@ -63,11 +57,9 @@ export function EmailSettings({
|
||||
onChange={e => onFromNameChange(e.target.value)}
|
||||
placeholder={fromNamePlaceholder}
|
||||
/>
|
||||
{showFromNameHelpText && (
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The sender name that appears in the recipient's inbox. Defaults to your project name if not set.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
The sender name that appears in the recipient's inbox. Defaults to your project name if not set.
|
||||
</p>
|
||||
</div>
|
||||
</GridWrapper>
|
||||
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import type {LucideIcon} from 'lucide-react';
|
||||
import type {ReactNode} from 'react';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({icon: Icon, title, description, action}: EmptyStateProps) {
|
||||
return (
|
||||
<div className="text-center py-14">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-md border border-neutral-200 bg-neutral-50 mb-4">
|
||||
<Icon className="h-5 w-5 text-neutral-400" />
|
||||
</div>
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-1">{title}</h3>
|
||||
<p className="text-sm text-neutral-500 max-w-xs mx-auto leading-relaxed mb-5">{description}</p>
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -114,13 +114,7 @@ export function KeyValueEditor({initialData, onChange}: KeyValueEditorProps) {
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removePair(pair.id)}
|
||||
className="text-red-500 hover:text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Button type="button" variant="destructiveGhost" size="icon" onClick={() => removePair(pair.id)}>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||