From 683356c17ca5920a0e0b6f1337d3b589aacbc7cd Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Sun, 7 Dec 2025 13:36:31 +0100 Subject: [PATCH] Update wiki --- .../conditional-branching.mdx | 135 +---- .../docs/automation-patterns/index.mdx | 53 -- .../docs/automation-patterns/meta.json | 15 +- .../visual-builder-guide.mdx | 275 +--------- .../automation-patterns/workflow-patterns.mdx | 279 +--------- .../content/docs/concepts/attachments.mdx | 46 ++ .../docs/concepts/campaigns-vs-workflows.mdx | 120 +---- .../docs/concepts/contacts-and-data.mdx | 214 ++------ .../content/docs/concepts/custom-domains.mdx | 43 ++ .../docs/concepts/events-and-triggers.mdx | 175 +------ apps/wiki/content/docs/concepts/index.mdx | 49 -- apps/wiki/content/docs/concepts/meta.json | 7 +- .../docs/concepts/segments-and-filters.mdx | 164 +----- .../content/docs/concepts/templates-types.mdx | 114 +---- .../content/docs/concepts/troubleshooting.mdx | 44 ++ apps/wiki/content/docs/concepts/webhooks.mdx | 47 ++ apps/wiki/content/docs/guides/analytics.mdx | 181 ------- .../content/docs/guides/billing-limits.mdx | 115 ----- apps/wiki/content/docs/guides/campaigns.mdx | 247 --------- .../wiki/content/docs/guides/contact-data.mdx | 205 -------- apps/wiki/content/docs/guides/contacts.mdx | 436 ---------------- .../content/docs/guides/custom-domains.mdx | 209 -------- .../content/docs/guides/email-attachments.mdx | 385 -------------- apps/wiki/content/docs/guides/events.mdx | 223 -------- apps/wiki/content/docs/guides/meta.json | 17 - apps/wiki/content/docs/guides/request-ids.mdx | 307 ----------- .../content/docs/guides/scaling-email.mdx | 277 ---------- apps/wiki/content/docs/guides/segments.mdx | 342 ------------- apps/wiki/content/docs/guides/templates.mdx | 287 ----------- .../content/docs/guides/troubleshooting.mdx | 427 ---------------- apps/wiki/content/docs/guides/webhooks.mdx | 262 ---------- .../docs/guides/workflow-automation.mdx | 211 -------- apps/wiki/content/docs/guides/workflows.mdx | 445 ---------------- apps/wiki/content/docs/meta.json | 4 +- .../docs/self-hosting/database-setup.mdx | 228 --------- .../wiki/content/docs/self-hosting/docker.mdx | 410 ++------------- .../content/docs/self-hosting/email-setup.mdx | 270 +++------- .../self-hosting/environment-variables.mdx | 200 ++------ .../docs/self-hosting/introduction.mdx | 158 +----- apps/wiki/content/docs/self-hosting/meta.json | 2 +- .../tutorials/cart-abandonment-automation.mdx | 227 --------- .../tutorials/event-tracking-integration.mdx | 475 ------------------ .../tutorials/first-transactional-email.mdx | 153 +----- apps/wiki/content/docs/tutorials/index.mdx | 37 -- apps/wiki/content/docs/tutorials/meta.json | 11 +- .../docs/tutorials/newsletter-campaign.mdx | 297 +---------- .../tutorials/segment-based-targeting.mdx | 295 +---------- .../tutorials/welcome-series-workflow.mdx | 130 +---- 48 files changed, 633 insertions(+), 8620 deletions(-) delete mode 100644 apps/wiki/content/docs/automation-patterns/index.mdx create mode 100644 apps/wiki/content/docs/concepts/attachments.mdx create mode 100644 apps/wiki/content/docs/concepts/custom-domains.mdx delete mode 100644 apps/wiki/content/docs/concepts/index.mdx create mode 100644 apps/wiki/content/docs/concepts/troubleshooting.mdx create mode 100644 apps/wiki/content/docs/concepts/webhooks.mdx delete mode 100644 apps/wiki/content/docs/guides/analytics.mdx delete mode 100644 apps/wiki/content/docs/guides/billing-limits.mdx delete mode 100644 apps/wiki/content/docs/guides/campaigns.mdx delete mode 100644 apps/wiki/content/docs/guides/contact-data.mdx delete mode 100644 apps/wiki/content/docs/guides/contacts.mdx delete mode 100644 apps/wiki/content/docs/guides/custom-domains.mdx delete mode 100644 apps/wiki/content/docs/guides/email-attachments.mdx delete mode 100644 apps/wiki/content/docs/guides/events.mdx delete mode 100644 apps/wiki/content/docs/guides/meta.json delete mode 100644 apps/wiki/content/docs/guides/request-ids.mdx delete mode 100644 apps/wiki/content/docs/guides/scaling-email.mdx delete mode 100644 apps/wiki/content/docs/guides/segments.mdx delete mode 100644 apps/wiki/content/docs/guides/templates.mdx delete mode 100644 apps/wiki/content/docs/guides/troubleshooting.mdx delete mode 100644 apps/wiki/content/docs/guides/webhooks.mdx delete mode 100644 apps/wiki/content/docs/guides/workflow-automation.mdx delete mode 100644 apps/wiki/content/docs/guides/workflows.mdx delete mode 100644 apps/wiki/content/docs/self-hosting/database-setup.mdx delete mode 100644 apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx delete mode 100644 apps/wiki/content/docs/tutorials/event-tracking-integration.mdx delete mode 100644 apps/wiki/content/docs/tutorials/index.mdx diff --git a/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx b/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx index 8b413a8..a92b0f2 100644 --- a/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx +++ b/apps/wiki/content/docs/automation-patterns/conditional-branching.mdx @@ -9,127 +9,38 @@ icon: GitBranch Conditions split workflows into two paths based on contact data. ``` -[Condition: plan equals "premium"] - ├─ True → [Send: Premium features] - └─ False → [Send: Upgrade offer] +[Condition: plan = "premium"?] + ├─ True → [Premium email] + └─ False → [Upgrade offer] ``` -Both paths required. - ## Operators -| Operator | Use | Example | -|----------|-----|---------| -| `equals` | Exact match | `plan equals "pro"` | -| `notEquals` | Not matching | `plan notEquals "free"` | -| `contains` | Substring | `company contains "tech"` | -| `greaterThan` | Greater than | `mrr greaterThan 100` | -| `lessThan` | Less than | `loginCount lessThan 5` | -| `greaterThanOrEqual` | Greater than or equal | `age greaterThanOrEqual 18` | -| `lessThanOrEqual` | Less than or equal | `daysInactive lessThanOrEqual 30` | -| `exists` | Has value | `company exists` | -| `notExists` | Missing/null | `lastName notExists` | -| `startsWith` | Prefix | `coupon startsWith "SAVE"` | -| `endsWith` | Suffix | `email endsWith "@company.com"` | +| Operator | Example | +|----------|---------| +| `equals` | `plan equals "pro"` | +| `notEquals` | `plan notEquals "free"` | +| `contains` | `email contains "@company.com"` | +| `greaterThan` | `mrr greaterThan 100` | +| `lessThan` | `loginCount lessThan 5` | +| `exists` | `company exists` | +| `notExists` | `lastName notExists` | -**Important:** Numeric operators require field stored as number, not string. - -## Common patterns - -### Filter by plan - -``` -[Condition: plan equals "enterprise"] - ├─ True → [Send: Enterprise onboarding] - └─ False → [Send: Standard onboarding] -``` - -### Activity check - -``` -[Condition: loginCount greaterThan 10] - ├─ True → [Send: Power user tips] - └─ False → [Send: Getting started] -``` - -### Nested conditions +## Nested conditions Chain for multi-tier logic: ``` -[Condition: plan equals "enterprise"] - ├─ True → [Send: Enterprise email] - └─ False ↓ - [Condition: plan equals "pro"] - ├─ True → [Send: Pro email] - └─ False → [Send: Free email] +[Condition: enterprise?] + ├─ True → [Enterprise email] + └─ False → [Condition: pro?] + ├─ True → [Pro email] + └─ False → [Free email] ``` -### Multiple field checks (AND) +## Tips -Nest conditions: - -``` -[Condition: plan equals "pro"] - ├─ True ↓ - │ [Condition: trialDaysLeft lessThan 3] - │ ├─ True → [Send: Trial ending] - │ └─ False → [Exit] - └─ False → [Exit] -``` - -Matches: `plan = "pro"` AND `trialDaysLeft < 3` - -## Nested data - -Access with dot notation: - -```json -{ - "field": "preferences.newsletter", - "operator": "equals", - "value": true -} -``` - -## Best practices - -**Store correct types** — `99` (number) not `"99"` (string) for numeric comparisons. - -**Check existence first** — If field might not exist: - -``` -[Condition: mrr exists] - ├─ True → [Condition: mrr greaterThan 100] - └─ False → [Exit] -``` - -**Limit nesting** — More than 3 levels gets hard to maintain. Use separate workflows. - -**Test both paths** — Verify true and false outcomes work. - -**Use segments when filtering many** — Segment-based triggers more efficient than in-workflow conditions for large audiences. - -## Common mistakes - -**Case sensitivity** — `equals "Pro"` doesn't match `"pro"` - -**Type mismatch** — `"100" greaterThan 50` fails (string vs number) - -**Missing both paths** — Every condition needs true AND false connections - -## Debugging - -Check contact data first: - -```bash -curl -X GET {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Verify field names and types match your condition. - -## Next steps - -- [See workflow patterns](/automation-patterns/workflow-patterns) -- [Build segments](/guides/segments) for trigger filtering +- Store numbers as numbers, not strings +- Check field exists before comparing +- Limit to 3 levels of nesting +- Test both paths diff --git a/apps/wiki/content/docs/automation-patterns/index.mdx b/apps/wiki/content/docs/automation-patterns/index.mdx deleted file mode 100644 index 8859ada..0000000 --- a/apps/wiki/content/docs/automation-patterns/index.mdx +++ /dev/null @@ -1,53 +0,0 @@ ---- -title: Automation Patterns -description: Workflow patterns and examples -icon: Zap ---- - -## Learn workflows - - - - Use the drag-and-drop workflow editor - - - - Common workflow examples - - - -## By use case - - - - Welcome and activate new users - - - - Drive feature adoption - - - - Prevent churn and win back users - - - - Order confirmations and receipts - - - -## Advanced - - - - If/then logic in workflows - - - - Pass event data through workflows - - - - Debug workflow issues - - diff --git a/apps/wiki/content/docs/automation-patterns/meta.json b/apps/wiki/content/docs/automation-patterns/meta.json index cfc1357..3d8226b 100644 --- a/apps/wiki/content/docs/automation-patterns/meta.json +++ b/apps/wiki/content/docs/automation-patterns/meta.json @@ -1,15 +1,4 @@ { - "title": "Automation Patterns", - "pages": [ - "index", - "visual-builder-guide", - "workflow-patterns", - "onboarding-sequences", - "engagement-campaigns", - "retention-automation", - "transactional-workflows", - "conditional-branching", - "advanced-execution-context", - "troubleshooting-workflows" - ] + "title": "Automation", + "pages": ["visual-builder-guide", "workflow-patterns", "conditional-branching"] } diff --git a/apps/wiki/content/docs/automation-patterns/visual-builder-guide.mdx b/apps/wiki/content/docs/automation-patterns/visual-builder-guide.mdx index b66ba0f..d547de2 100644 --- a/apps/wiki/content/docs/automation-patterns/visual-builder-guide.mdx +++ b/apps/wiki/content/docs/automation-patterns/visual-builder-guide.mdx @@ -1,258 +1,37 @@ --- -title: Visual Workflow Builder +title: Visual Builder description: Build workflows with drag-and-drop icon: Workflow --- -## Overview - -The workflow builder is a node-based visual editor for creating email automation. Each node is a step (send email, delay, condition, etc.) and connections define the flow. - ## Canvas controls -- **-** / **+** - Zoom out/in -- **Fit** - Center and zoom to show entire workflow -- **Auto-Layout** - Automatically arrange nodes -- **Minimap** - Toggle overview map (bottom right) +- **+/-** — Zoom in/out +- **Fit** — Center workflow +- **Auto-Layout** — Arrange nodes ## Step types -### Trigger - -The starting point. Every workflow has one trigger. - -**Types:** -- Event (e.g., `user_signed_up`) -- Segment entry/exit -- Schedule (cron) - -Cannot be deleted. - -### Send Email - -Sends an email to the contact. - -**Configuration:** -- Template (required) -- Variables (optional overrides) - -Contact must be subscribed for marketing templates. Transactional templates send regardless. - -### Delay - -Pauses execution for a specified time. - -**Configuration:** -- Duration: Number -- Unit: Minutes, Hours, or Days - -**Example:** Delay 24 hours before next email. - -### Wait for Event - -Pauses until an event occurs or timeout is reached. - -**Configuration:** -- Event name -- Timeout duration (optional) - -**Requires two outgoing connections:** -- Event triggered path -- Timeout path - -**Example:** -``` -[Wait for: purchase_completed, timeout: 48h] -├─ Purchased → Send thank you -└─ Timeout → Send discount reminder -``` - -### Condition - -Branches workflow based on contact data. - -**Configuration:** -- Field to check -- Operator (equals, contains, greaterThan, etc.) -- Value to compare - -**Requires two outgoing connections:** -- True path -- False path - -**Example:** -``` -[Condition: plan equals "premium"] -├─ True → Send premium features -└─ False → Send upgrade offer -``` - -### Webhook - -Sends HTTP request to external URL. - -**Configuration:** -- URL -- Method (GET, POST, PUT, DELETE) -- Headers (optional) -- Body (JSON, optional) - -**Example:** Update CRM when workflow completes. - -### Update Contact - -Updates contact data fields. - -**Configuration:** -- Fields: Key-value pairs - -**Example:** -``` -Fields: - onboardingCompleted: true - lastWorkflowStep: "welcome_series_done" -``` - -### Exit - -Ends workflow execution. Can have multiple exit points for different paths. - -## Building a workflow - -### Add a step - -**Method 1:** Click **+** button below any node - -**Method 2:** Drag step type from sidebar onto canvas - -### Configure a step - -1. Click node to select -2. Right panel opens -3. Fill required fields -4. Save - -### Connect steps - -Connections auto-create when using + button. To manually connect: -1. Drag from node's bottom handle -2. Drop on another node's top handle - -### Arrange layout - -Click **Auto-Layout** for automatic top-to-bottom arrangement. - -## Example workflows - -### Linear sequence - -``` -[Trigger: user_signed_up] - ↓ -[Send: Welcome] - ↓ -[Delay: 24h] - ↓ -[Send: Feature tour] - ↓ -[Exit] -``` - -### With condition - -``` -[Trigger: trial_started] - ↓ -[Send: Welcome] - ↓ -[Condition: plan equals "enterprise"] - ├─ True → [Send: Enterprise onboarding] - └─ False → [Send: Standard onboarding] -``` - -### With event wait - -``` -[Trigger: cart_abandoned] - ↓ -[Delay: 1h] - ↓ -[Send: First reminder] - ↓ -[Wait for: purchase_completed, timeout: 23h] - ├─ Purchased → [Exit] - └─ Timeout → [Send: Discount offer] → [Exit] -``` - -## Validation - -The builder validates in real-time. Red border = error. - -**Common errors:** -- "No outgoing connections" → Add connection to next step -- "Wait for Event requires two paths" → Add event + timeout paths -- "Condition requires true and false paths" → Add both paths -- "Template not found" → Select valid template - -## Testing - -### Manual execution - -1. **Workflows** → Your workflow → **Executions** -2. **Create Execution** -3. Select test contact -4. Provide test data (JSON) -5. **Start** - -Watch execution progress in real-time. - -### Faster testing - -For testing delays: -- Temporarily change to 5 minutes instead of days -- Test the flow -- Change back to real durations - -## Keyboard shortcuts - -| Key | Action | -|-----|--------| -| Delete | Delete selected node | -| Cmd/Ctrl + Z | Undo | -| Cmd/Ctrl + Shift + Z | Redo | -| + / - | Zoom | -| F | Fit to screen | -| Esc | Deselect | - -## Troubleshooting - -**Workflow not triggering** -- Workflow is enabled (toggle ON) -- Event name matches exactly (case-sensitive) -- Contact exists and is subscribed - -**Execution stuck** -- Check delay configuration -- Verify wait timeout hasn't expired -- No circular references - -**Email not sending** -- Template exists -- Contact is subscribed -- Variables in data match template - -## Best practices - -**Keep it simple** - 5-10 steps per workflow. Break complex flows into multiple workflows. - -**Descriptive names** - "Send Welcome Email - Day 0" not "Email 1" - -**Test first** - Always test with a test contact before enabling. - -**Space emails** - Minimum 12-24 hours between emails to avoid fatigue. - -## Next steps - -- [Build your first workflow](/tutorials/welcome-series-workflow) -- [Workflow patterns](/automation-patterns/workflow-patterns) -- [Conditional branching](/automation-patterns/conditional-branching) +| Step | Description | +|------|-------------| +| **Trigger** | Starting point (event, segment, schedule) | +| **Send Email** | Send a template | +| **Delay** | Wait minutes, hours, or days | +| **Wait for Event** | Pause until event or timeout | +| **Condition** | Branch based on contact data | +| **Webhook** | Send HTTP request | +| **Update Contact** | Modify contact fields | +| **Exit** | End workflow | + +## Building + +1. Click step to configure +2. Connect steps by dragging +3. Conditions require both true/false paths + +## Tips + +- Keep workflows to 5-10 steps +- Use descriptive step names +- Test with a test contact before enabling +- Space emails 12-24 hours apart diff --git a/apps/wiki/content/docs/automation-patterns/workflow-patterns.mdx b/apps/wiki/content/docs/automation-patterns/workflow-patterns.mdx index 4288e89..f951e3e 100644 --- a/apps/wiki/content/docs/automation-patterns/workflow-patterns.mdx +++ b/apps/wiki/content/docs/automation-patterns/workflow-patterns.mdx @@ -1,294 +1,49 @@ --- -title: Common Workflow Patterns -description: Reusable workflow templates +title: Workflow Patterns +description: Common workflow templates icon: Layers --- ## Linear sequence -Send a series of emails with delays between them. - ``` -[Trigger: event] - ↓ -[Send Email 1] - ↓ -[Delay] - ↓ -[Send Email 2] - ↓ -[Delay] - ↓ -[Send Email 3] - ↓ -[Exit] +[Trigger] → [Email] → [Delay] → [Email] → [Exit] ``` -**Use for:** -- Onboarding series -- Educational drip campaigns -- Feature introduction sequences - -**Example timing:** -- Day 0: Welcome -- Day 1: Getting started guide -- Day 3: Tips and tricks -- Day 7: Feature deep dive +Use for: onboarding, drip campaigns ## Wait and branch -Pause for an event, then branch based on outcome. - ``` -[Trigger: event] - ↓ -[Wait for Event: timeout X days] - ├─ Event occurred → [Send success email] → [Exit] - └─ Timeout → [Send reminder email] → [Exit] +[Trigger] → [Wait for Event] + ├─ Event → [Success email] → [Exit] + └─ Timeout → [Reminder] → [Exit] ``` -**Use for:** -- Trial conversion (waiting for subscription) -- Activation campaigns (waiting for key action) -- Re-engagement (waiting for login) - -**Example:** -``` -[Trigger: trial_started] - ↓ -[Send: Welcome to trial] - ↓ -[Wait for: subscription_created, timeout: 7 days] - ├─ Subscribed → [Send: Thanks for subscribing] → [Exit] - └─ Timeout → [Send: Last chance offer] → [Exit] -``` +Use for: trial conversion, activation ## Conditional branch -Split workflow based on contact data. - ``` -[Trigger: event] - ↓ -[Condition: check contact field] - ├─ True → [Send email A] → [Exit] - └─ False → [Send email B] → [Exit] +[Trigger] → [Condition: plan = premium?] + ├─ True → [Premium email] → [Exit] + └─ False → [Upgrade offer] → [Exit] ``` -**Use for:** -- Personalization by plan/tier -- Segmented messaging -- Feature availability checks +Use for: personalization by tier -**Example:** -``` -[Trigger: signed_up] - ↓ -[Condition: plan equals "enterprise"] - ├─ True → [Send: Enterprise onboarding] → [Exit] - └─ False → [Send: Standard onboarding] → [Exit] -``` - -## Multi-condition cascade - -Chain multiple conditions for complex logic. - -``` -[Trigger: event] - ↓ -[Condition: is premium?] - ├─ True → [Send: Premium content] → [Exit] - └─ False ↓ - [Condition: trial active?] - ├─ True → [Send: Trial content] → [Exit] - └─ False → [Send: Free content] → [Exit] -``` - -**Use for:** -- Tiered content delivery -- Progressive feature reveals -- Access-based messaging - -## Wait chain - -Multiple wait steps with progressive urgency. +## Multi-step recovery ``` [Trigger: cart_abandoned] ↓ [Delay: 1 hour] ↓ -[Send: Gentle reminder] +[Send: Reminder] ↓ -[Wait for: purchase, timeout: 23 hours] +[Wait for: purchase, timeout: 24h] ├─ Purchased → [Exit] - └─ Timeout ↓ - [Send: Discount offer] - ↓ - [Wait for: purchase, timeout: 48 hours] - ├─ Purchased → [Exit] - └─ Timeout → [Send: Final reminder] → [Exit] + └─ Timeout → [Send: Discount] → [Exit] ``` -**Use for:** -- Cart abandonment -- Multi-touch re-engagement -- Escalating incentives - -## Segment entry drip - -Triggered when contact joins segment. - -``` -[Trigger: enters "Inactive Users" segment] - ↓ -[Send: We miss you] - ↓ -[Delay: 3 days] - ↓ -[Condition: still in segment?] - ├─ Yes → [Send: Special offer] → [Exit] - └─ No → [Exit] -``` - -**Use for:** -- Churn prevention -- Win-back campaigns -- Behavior-based sequences - -**Important:** Segment must have `trackMembership: true` enabled. - -## Schedule with filter - -Runs on schedule, filtered by conditions. - -``` -[Trigger: daily at 9am] - ↓ -[Condition: trial expires in 3 days] - ├─ True → [Send: Trial expiring] → [Exit] - └─ False → [Exit] -``` - -**Use for:** -- Trial expiration reminders -- Subscription renewal notices -- Scheduled checks with conditional sends - -## Update and continue - -Modify contact data mid-workflow. - -``` -[Trigger: signed_up] - ↓ -[Send: Welcome] - ↓ -[Update Contact: onboardingStep = 1] - ↓ -[Delay: 1 day] - ↓ -[Send: Getting started] - ↓ -[Update Contact: onboardingStep = 2] - ↓ -[Exit] -``` - -**Use for:** -- Tracking workflow progress -- Triggering other systems via segment changes -- Marking completion milestones - -## Webhook integration - -Call external API during workflow. - -``` -[Trigger: purchase_completed] - ↓ -[Send: Order confirmation] - ↓ -[Delay: 7 days] - ↓ -[Webhook: Create review request in external system] - ↓ -[Send: Review request] - ↓ -[Exit] -``` - -**Use for:** -- Syncing with CRM -- Triggering other automation tools -- Logging workflow events - -## Complex multi-path - -Combine patterns for sophisticated flows. - -``` -[Trigger: trial_started] - ↓ -[Send: Welcome] - ↓ -[Delay: 1 day] - ↓ -[Send: Feature tour] - ↓ -[Wait for: feature_used, timeout: 3 days] - ├─ Used ↓ - │ [Send: Great job] - │ ↓ - │ [Delay: 3 days] - │ ↓ - │ [Condition: trial ending in 1 day] - │ ├─ True → [Send: Upgrade reminder] → [Exit] - │ └─ False → [Exit] - │ - └─ Timeout ↓ - [Send: Help offer] - ↓ - [Wait for: feature_used, timeout: 3 days] - ├─ Used → [Send: Nice work] → [Exit] - └─ Timeout → [Update Contact: needs_help = true] → [Exit] -``` - -**Use for:** -- Advanced onboarding -- Complex user journeys -- Adaptive content delivery - -## Best practices - -**Keep it simple** — Start with 3-5 steps. Add complexity only when needed. - -**Exit explicitly** — Every path should end at an Exit step. - -**Handle both outcomes** — Conditions and waits need success + failure paths. - -**Test incrementally** — Build and test each section before adding more. - -**Use meaningful delays** — Don't spam. Minimum 12-24 hours between emails. - -**Name clearly** — "Send Welcome Email - Day 0" not "Email 1". - -**Monitor drop-off** — Check execution stats to see where contacts exit. - -**Prevent loops** — Never create circular paths. Workflow should always progress forward. - -## Performance tips - -**Batch schedule triggers** — If using schedules, run at off-peak hours to spread load. - -**Limit concurrent waits** — Long waits (weeks/months) keep executions in memory. Use scheduled workflows for very long delays. - -**Avoid deep nesting** — More than 3-4 condition levels becomes hard to maintain. - -**Use segments over conditions** — If filtering many contacts, segment-based triggers are more efficient than in-workflow conditions. - -## Next steps - -- [Build conditional logic](/automation-patterns/conditional-branching) -- [Understand execution context](/automation-patterns/advanced-execution-context) -- [Troubleshoot workflows](/automation-patterns/troubleshooting-workflows) +Use for: cart abandonment, re-engagement diff --git a/apps/wiki/content/docs/concepts/attachments.mdx b/apps/wiki/content/docs/concepts/attachments.mdx new file mode 100644 index 0000000..d9f97a5 --- /dev/null +++ b/apps/wiki/content/docs/concepts/attachments.mdx @@ -0,0 +1,46 @@ +--- +title: Email Attachments +description: Send files with emails +icon: Paperclip +--- + +## Limits + +- Max 10 attachments per email +- Max 10MB total size +- Any file type supported + +## API usage + +```json +{ + "to": "user@example.com", + "subject": "Your Invoice", + "body": "

Invoice attached.

", + "attachments": [ + { + "filename": "invoice.pdf", + "content": "JVBERi0xLjQK...", + "contentType": "application/pdf" + } + ] +} +``` + +## Attachment fields + +| Field | Description | +|-------|-------------| +| `filename` | Display name (max 255 chars) | +| `content` | Base64-encoded file content | +| `contentType` | MIME type (e.g., `application/pdf`) | + +## Base64 encoding + +```javascript +import fs from 'fs'; + +const fileBuffer = fs.readFileSync('invoice.pdf'); +const base64Content = fileBuffer.toString('base64'); +``` + diff --git a/apps/wiki/content/docs/concepts/campaigns-vs-workflows.mdx b/apps/wiki/content/docs/concepts/campaigns-vs-workflows.mdx index 39c46c5..8cce205 100644 --- a/apps/wiki/content/docs/concepts/campaigns-vs-workflows.mdx +++ b/apps/wiki/content/docs/concepts/campaigns-vs-workflows.mdx @@ -1,37 +1,26 @@ --- title: Campaigns vs Workflows -description: Choose the right tool for sending emails +description: Choose the right sending method icon: GitCompare --- -## When to use each +## Quick comparison -**Transactional API** (`/v1/send`) — Immediate one-off emails from your code -- Password resets, confirmations, receipts -- Triggered directly by user actions -- Instant delivery - -**Campaigns** — One-time broadcasts to many contacts -- Newsletters, announcements, promotions -- Created in dashboard, send now or schedule -- No code required - -**Workflows** — Automated multi-email sequences -- Onboarding series, abandoned cart, trial reminders -- Triggered by events, segment changes, or schedules -- Delays, conditions, multiple emails +| Need | Use | +|------|-----| +| Password reset now | **API** `/v1/send` | +| Monthly newsletter | **Campaign** | +| Welcome series over 3 days | **Workflow** | +| Order confirmation | **API** `/v1/send` | +| Abandoned cart recovery | **Workflow** | ## Transactional API -Send emails directly from your application code. +Immediate one-off emails from your code. ```javascript -await fetch('{{API_URL}}/v1/send', { +await fetch('/v1/send', { method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, body: JSON.stringify({ to: user.email, subject: 'Reset your password', @@ -40,90 +29,19 @@ await fetch('{{API_URL}}/v1/send', { }); ``` -**Use for:** -- Emails that must send immediately -- One-to-one triggered emails -- Context-specific data (reset tokens, order IDs) - ## Campaigns -Create and send broadcasts in the dashboard. +One-time broadcasts to many contacts. Created in dashboard. -1. Create campaign -2. Write email content -3. Select audience (all contacts, segment, or filter) -4. Send now or schedule - -**Use for:** -- One-time sends to many people -- Scheduled announcements -- Manual email sends - -**Can't do:** -- Automation or triggers -- Multi-step sequences -- Delays between emails +- Send now or schedule +- Target all contacts, segments, or filters +- No code required ## Workflows -Build automated sequences with the visual workflow builder. +Automated multi-email sequences. -**Example workflow:** -``` -[Trigger: user_signed_up] - ↓ -[Send Email: Welcome] - ↓ -[Delay: 24 hours] - ↓ -[Send Email: Feature tour] - ↓ -[Delay: 48 hours] - ↓ -[Send Email: Help offer] -``` - -**Use for:** -- Multi-email sequences -- Time-delayed follow-ups -- Event-triggered automation +- Triggered by events or segment changes +- Delays between emails - Conditional logic (if/then) - -**Setup required:** -- Track events from your app via `/v1/track` -- Create workflow in dashboard -- Enable workflow - -## Decision matrix - -| Need | Use | -|------|-----| -| Send password reset now | Transactional API | -| Send monthly newsletter | Campaign | -| Send welcome series over 3 days | Workflow | -| Send order confirmation | Transactional API | -| Send announcement to all users | Campaign | -| Send trial reminder 3 days before expiration | Workflow | -| Send receipt after payment | Transactional API | -| Send seasonal promotion | Campaign | -| Send abandoned cart recovery (1hr + 24hr) | Workflow | - -## Using them together - -Most apps use all three: - -**SaaS example:** -- **Transactional**: Password resets, email verification -- **Campaigns**: Monthly product updates -- **Workflows**: Trial onboarding, churn prevention - -**E-commerce example:** -- **Transactional**: Order confirmations, shipping updates -- **Campaigns**: Weekly deals newsletter -- **Workflows**: Abandoned cart, review requests - -## Next steps - -- [Send a transactional email](/tutorials/first-transactional-email) -- [Create your first workflow](/tutorials/welcome-series-workflow) -- [Build a campaign](/tutorials/newsletter-campaign) +- Re-entry control diff --git a/apps/wiki/content/docs/concepts/contacts-and-data.mdx b/apps/wiki/content/docs/concepts/contacts-and-data.mdx index 26a4160..a002436 100644 --- a/apps/wiki/content/docs/concepts/contacts-and-data.mdx +++ b/apps/wiki/content/docs/concepts/contacts-and-data.mdx @@ -1,18 +1,10 @@ --- -title: Contacts and Data +title: Contacts description: Store and manage your audience icon: Users --- -## What are contacts - -Contacts are people in your email list. Each contact has: - -- **Email** — Unique identifier -- **Subscription status** — Subscribed or unsubscribed -- **Custom data** — Any fields you need - -## Data structure +## Structure ```json { @@ -27,188 +19,42 @@ Contacts are people in your email list. Each contact has: } ``` -The `data` field stores custom information as key-value pairs. +## Adding contacts -## Data types +- **Dashboard:** Contacts → Add Contact +- **CSV import:** Contacts → Import +- **API:** `POST /contacts` +- **Events:** Auto-created when tracking events -**Strings:** -```json -{ "firstName": "Sarah", "company": "Acme Inc" } +## Contact data + +The `data` field stores custom key-value pairs. + +**Best practices:** +- Use consistent naming (camelCase or snake_case) +- Store dates as ISO strings: `"2024-03-15T10:30:00Z"` +- Use numbers for numeric values (enables comparisons) + +## Template variables + +Use `{{fieldName}}` in emails: + +```html +

Hello {{firstName}}!

``` -**Numbers:** -```json -{ "mrr": 99, "loginCount": 15 } -``` +**Fallback:** `{{firstName ?? 'there'}}` -Store as numbers for `greaterThan`/`lessThan` comparisons. +**Reserved:** `{{email}}`, `{{id}}` -**Booleans:** -```json -{ "verified": true, "newsletter": false } -``` +## Temporary data -**Dates:** -```json -{ "signupDate": "2024-03-15T10:30:00Z" } -``` +Data that won't save to contact: -Use ISO 8601 format. - -**Arrays:** -```json -{ "tags": ["vip", "enterprise"] } -``` - -**Objects:** -```json -{ - "address": { - "city": "San Francisco", - "country": "US" - } +```javascript +data: { + resetCode: { value: 'ABC123', persistent: false } } ``` -Access nested fields: `address.country` - -## Creating contacts - -### Via API - -```bash -curl -X POST {{API_URL}}/contacts \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "subscribed": true, - "data": { - "firstName": "Sarah", - "plan": "pro" - } - }' -``` - -### Automatic upsert - -If email exists, updates instead of creating duplicate. - -```javascript -// First call -POST /contacts { email: "user@example.com", data: { plan: "free" } } - -// Second call - updates same contact -POST /contacts { email: "user@example.com", data: { mrr: 99 } } - -// Result: { plan: "free", mrr: 99 } -``` - -Data merges automatically. - -### Via event tracking - -```bash -curl -X POST {{API_URL}}/v1/track \ - -H "Authorization: Bearer pk_your_public_key" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "event": "signed_up", - "data": { - "plan": "pro", - "source": "landing" - } - }' -``` - -Creates contact if doesn't exist, updates if does. - -## Updating contacts - -```bash -curl -X PATCH {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "data": { - "plan": "premium", - "mrr": 199 - } - }' -``` - -New fields added, existing fields overwritten, unmentioned fields preserved. - -### Remove fields - -Set to `null`: - -```json -{ "data": { "temporaryToken": null } } -``` - -## Subscription status - -**Subscribed (true):** -- Receives marketing emails -- Receives transactional emails - -**Unsubscribed (false):** -- Does NOT receive marketing emails -- Still receives transactional emails - -Template type controls this behavior. See [Template Types](/concepts/templates-types). - -## Using contact data - -### In templates - -```html -

Hi {{firstName}}!

-

Your {{plan}} plan renews on {{renewalDate}}.

-``` - -### In segments - -Filter by data fields: -- `plan equals "premium"` -- `mrr greaterThan 100` -- `loginCount lessThan 5` - -### In workflows - -``` -[Condition: plan equals "enterprise"] - ├─ True → [Send: Enterprise content] - └─ False → [Send: Standard content] -``` - -## Best practices - -**Consistent naming** — Use camelCase or snake_case, not both. - -**Correct types** — Use `99` not `"99"` for numbers. - -**ISO dates** — `"2024-03-15T10:30:00Z"` for date fields. - -**Sync critical fields only** — Don't mirror entire database. Only fields used in emails, segments, or workflows. - -**Update in real-time** — When user data changes, update contact immediately. - -**Respect unsubscribes** — Never re-subscribe automatically. - -## Deleting contacts - -```bash -curl -X DELETE {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Permanent deletion. Consider unsubscribing instead to preserve history. - -## Next steps - -- [Create segments](/concepts/segments-and-filters) -- [Track events](/concepts/events-and-triggers) -- [Use templates](/concepts/templates-types) +Use for: one-time codes, tokens, session data. diff --git a/apps/wiki/content/docs/concepts/custom-domains.mdx b/apps/wiki/content/docs/concepts/custom-domains.mdx new file mode 100644 index 0000000..8c23261 --- /dev/null +++ b/apps/wiki/content/docs/concepts/custom-domains.mdx @@ -0,0 +1,43 @@ +--- +title: Custom Domains +description: Send from your own domain +icon: Globe +--- + +## Why use custom domains + +- Better deliverability +- Brand consistency +- Builds sender reputation + +## Setup + +1. Go to **Settings → Domains** +2. Click **Add Domain** +3. Enter your domain +4. Add the provided DNS records (DKIM, MX, TXT) +5. Wait for verification (usually 10-30 minutes, up to 48 hours) + +## Using your domain + +Specify in the `from` field when sending: + +```json +{ + "to": "customer@example.com", + "from": "orders@yourdomain.com", + "subject": "Order confirmed", + "body": "

Your order has been confirmed.

" +} +``` + +## Troubleshooting + +**Domain won't verify:** +- Check DNS records are correct +- Wait for DNS propagation (up to 48 hours) +- Remove conflicting DKIM records from other services + +**Emails going to spam:** +- Warm up new domains with small volumes first +- Monitor bounce and complaint rates diff --git a/apps/wiki/content/docs/concepts/events-and-triggers.mdx b/apps/wiki/content/docs/concepts/events-and-triggers.mdx index 4076b69..3ae1a29 100644 --- a/apps/wiki/content/docs/concepts/events-and-triggers.mdx +++ b/apps/wiki/content/docs/concepts/events-and-triggers.mdx @@ -1,180 +1,53 @@ --- -title: Events and Triggers -description: Track behavior and trigger workflows +title: Events +description: Track actions and trigger workflows icon: Activity --- -## What are events +## Overview -Events track user actions from your application. Use them to update contact data, trigger workflows, and build segments. +Events track user actions. Use them to trigger workflows and update contact data. -## Tracking events - -### Basic event +## Tracking ```javascript -await fetch('{{API_URL}}/v1/track', { +await fetch('/v1/track', { method: 'POST', headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' + 'Authorization': 'Bearer pk_your_public_key' }, body: JSON.stringify({ event: 'signed_up', - email: user.email - }) -}); -``` - -Creates or updates contact. Event is recorded. - -### Event with data - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'purchase_completed', email: user.email, - data: { - plan: 'premium', - mrr: 99 - } + data: { plan: 'pro' } }) }); ``` -Updates contact data fields. Data persists on contact record. +Creates/updates contact and records the event. -## Public vs Secret keys +## Keys -### Public Key (pk_*) +- **Public key (pk_*):** Safe for client-side, only works with `/v1/track` +- **Secret key (sk_*):** Server-side only, full API access -- Safe for client-side code -- Only works with `/v1/track` -- Use in browser/mobile apps +## Event data -### Secret Key (sk_*) - -- Server-side only -- Works with all endpoints -- Full API access - -## Event naming - -Use clear, past-tense verbs with underscores: - -✅ `signed_up`, `purchase_completed`, `feature_activated` -❌ `signup`, `buy`, `feature` - -## Workflow triggers - -### Event trigger - -Workflow starts when event is tracked. - -``` -Trigger: event = "signed_up" -``` - -Track event: +**Persistent (default):** Saved to contact ```javascript -track('signed_up', 'user@example.com'); +data: { plan: 'premium' } ``` -Workflow starts for that contact. - -### Wait for event - -Workflow pauses until event occurs. - -``` -[Wait for Event: purchase_completed, timeout: 7 days] - ├─ Event occurred → [Send: Thank you] - └─ Timeout → [Send: Discount offer] -``` - -## Common patterns - -### User lifecycle - +**Non-persistent:** Available only to workflow ```javascript -// Signup -track('signed_up', email, { source: 'landing' }); - -// First login -track('first_login', email, { lastLoginAt: new Date().toISOString() }); - -// Subscription -track('subscription_created', email, { plan: 'premium', mrr: 99 }); +data: { + orderId: { value: 'order-123', persistent: false } +} ``` -### E-commerce +## Automatic events -```javascript -// Cart abandonment -track('cart_abandoned', email, { - cartTotal: 149.99, - cartUrl: `https://store.com/cart/${cartId}` -}); - -// Purchase -track('purchase_completed', email, { - orderId: order.id, - orderTotal: order.total -}); -``` - -## Event-based segments - -Track events to update contact data, then segment on that data. - -1. Track login: -```javascript -track('login', email, { - lastLoginAt: new Date().toISOString() -}); -``` - -2. Create segment: -``` -Field: lastLoginAt -Operator: greaterThan -Value: {{7_days_ago}} -``` - -Contacts auto-join segment when they log in. - -## Testing events - -**Dashboard:** Contacts → Search email → Events tab - -Shows all events for that contact. - -**Workflows:** Check executions after tracking event to verify workflow triggered. - -## Rate limits - -- Public key: 100 requests/minute -- Secret key: 1000 requests/minute - -## Common issues - -**Workflow not triggering:** -- Workflow is enabled -- Event name matches exactly (case-sensitive) -- Contact email is correct - -**Contact data not updating:** -- Field names are case-sensitive -- Values are correct type (number vs string) - -## Next steps - -- [Build event-triggered workflows](/tutorials/welcome-series-workflow) -- [Create event-based segments](/concepts/segments-and-filters) -- [Track events from your app](/tutorials/event-tracking-integration) +Plunk tracks these automatically: +- `email.sent`, `email.opened`, `email.clicked` +- `email.bounced`, `email.complained` +- `segment.entered`, `segment.exited` diff --git a/apps/wiki/content/docs/concepts/index.mdx b/apps/wiki/content/docs/concepts/index.mdx deleted file mode 100644 index 068c32d..0000000 --- a/apps/wiki/content/docs/concepts/index.mdx +++ /dev/null @@ -1,49 +0,0 @@ ---- -title: Core Concepts -description: Understand how Plunk works -icon: Lightbulb ---- - -## Email methods - - - - When to use each email method - - - -## Data & contacts - - - - Store and manage your audience - - - - Create dynamic audience groups - - - -## Sending emails - - - - Marketing vs Transactional templates - - - - Track behavior and trigger workflows - - - -## Performance & delivery - - - - Reach the inbox - - - - Optimize for millions of contacts - - diff --git a/apps/wiki/content/docs/concepts/meta.json b/apps/wiki/content/docs/concepts/meta.json index 41530b9..0f6ba09 100644 --- a/apps/wiki/content/docs/concepts/meta.json +++ b/apps/wiki/content/docs/concepts/meta.json @@ -1,13 +1,14 @@ { "title": "Core Concepts", "pages": [ - "index", "contacts-and-data", "templates-types", "campaigns-vs-workflows", "segments-and-filters", "events-and-triggers", - "email-deliverability", - "scale-and-performance" + "webhooks", + "custom-domains", + "attachments", + "troubleshooting" ] } diff --git a/apps/wiki/content/docs/concepts/segments-and-filters.mdx b/apps/wiki/content/docs/concepts/segments-and-filters.mdx index 7117520..ce8c7d4 100644 --- a/apps/wiki/content/docs/concepts/segments-and-filters.mdx +++ b/apps/wiki/content/docs/concepts/segments-and-filters.mdx @@ -1,150 +1,40 @@ --- -title: Segments and Filters -description: Create dynamic audience groups -icon: Filter +title: Segments +description: Dynamic audience groups +icon: Funnel --- -## What are segments +## Overview -Segments are dynamic groups of contacts based on data filters. They update automatically when contact data changes. +Segments are dynamic groups based on filters. They update automatically when contact data changes. -Use segments to: -- Target specific audiences in campaigns -- Trigger workflows when contacts enter/exit -- Filter contacts in dashboard +## Creating -## Creating segments +1. Go to **Segments** → **Create Segment** +2. Name your segment +3. Add filter conditions +4. Save -### In dashboard +## Operators -**Contacts** → **Segments** → **Create Segment** +| Operator | Example | +|----------|---------| +| `equals` | `plan equals "pro"` | +| `not equals` | `plan not equals "free"` | +| `contains` | `email contains "@gmail"` | +| `greater than` | `mrr greater than 100` | +| `less than` | `loginCount less than 5` | +| `exists` | `company exists` | -1. Name your segment -2. Add filters -3. Save +## Combining filters -### Via API +- **AND:** All conditions must match +- **OR:** Any condition can match -```bash -curl -X POST {{API_URL}}/segments \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Premium Users", - "filters": [ - { - "field": "plan", - "operator": "equals", - "value": "premium" - } - ] - }' -``` +## Membership tracking -## Filter operators +Enable **Track membership changes** to: +- Trigger workflows on segment entry/exit +- Generate `segment.entered` and `segment.exited` events -| Operator | Use | Example | -|----------|-----|---------| -| `equals` | Exact match | `plan equals "pro"` | -| `notEquals` | Not matching | `plan notEquals "free"` | -| `contains` | Substring (case-insensitive) | `email contains "@company.com"` | -| `greaterThan` | Number comparison | `mrr greaterThan 100` | -| `lessThan` | Number comparison | `loginCount lessThan 5` | -| `greaterThanOrEqual` | Inclusive comparison | `age greaterThanOrEqual 18` | -| `lessThanOrEqual` | Inclusive comparison | `daysInactive lessThanOrEqual 30` | -| `exists` | Field has value | `company exists` | -| `notExists` | Field missing/null | `lastName notExists` | -| `startsWith` | String prefix | `coupon startsWith "SAVE"` | -| `endsWith` | String suffix | `email endsWith ".edu"` | - -## Multiple filters (AND logic) - -All filters must match. - -```json -{ - "name": "Active Premium Users", - "filters": [ - { "field": "plan", "operator": "equals", "value": "premium" }, - { "field": "loginCount", "operator": "greaterThan", "value": 5 } - ] -} -``` - -Contact matches only if: `plan = "premium"` AND `loginCount > 5` - -## Dynamic updates - -Segments update automatically. - -**Example:** -1. Segment: `plan equals "premium"` -2. User upgrades: `plan` changes from "free" to "premium" -3. Contact auto-added to segment - -No manual refresh needed. - -## Workflow triggers - -Trigger workflows when contacts enter/exit segments. - -### Enable tracking - -```bash -curl -X PATCH {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{"trackMembership": true}' -``` - -Only enable for segments used as triggers. - -### Entry trigger - -``` -[Trigger: contact enters "Premium Users"] - ↓ -[Send: Welcome to premium] -``` - -### Exit trigger - -``` -[Trigger: contact exits "Trial Users"] - ↓ -[Send: Trial ended offer] -``` - -## Nested fields - -Access nested data with dot notation. - -```json -{ - "field": "preferences.newsletter", - "operator": "equals", - "value": true -} -``` - -## Best practices - -**Store correct types** — Use `99` not `"99"` for numbers. Use `true` not `"true"` for booleans. - -**Track membership sparingly** — Only enable on segments used as workflow triggers. - -**Name descriptively** — "High-Value Customers (MRR >$200)" not "Segment 3" - -## Common issues - -**Numeric filters not working** — Ensure field is stored as number, not string. - -**Case-sensitive matching** — `equals "Pro"` doesn't match `plan = "pro"`. Use `contains` for case-insensitive. - -**Missing field** — If field doesn't exist on contact, filter won't match. - -## Next steps - -- [Use segments in campaigns](/guides/campaigns) -- [Trigger workflows on segment entry](/guides/workflows) -- [Store contact data](/concepts/contacts-and-data) +Only enable for segments used as workflow triggers. diff --git a/apps/wiki/content/docs/concepts/templates-types.mdx b/apps/wiki/content/docs/concepts/templates-types.mdx index 5c3f3be..814d73f 100644 --- a/apps/wiki/content/docs/concepts/templates-types.mdx +++ b/apps/wiki/content/docs/concepts/templates-types.mdx @@ -1,112 +1,36 @@ --- title: Template Types -description: Marketing vs Transactional templates +description: Marketing vs Transactional icon: Mail --- -## Two template types +## Two types -| Template Type | Sends to Unsubscribed? | Use For | -|--------------|------------------------|---------| -| **Marketing** | No | Newsletters, promotions, announcements | -| **Transactional** | Yes | Receipts, confirmations, password resets | +| Type | Sends to Unsubscribed? | Use For | +|------|------------------------|---------| +| **Marketing** | No | Newsletters, promotions | +| **Transactional** | Yes | Receipts, password resets | -## Marketing templates +## Marketing -Only sends to subscribed contacts. +Only sends to subscribed contacts. Automatically includes unsubscribe link. -**Use for:** Newsletters, product updates, promotional emails. +## Transactional -**Behavior:** -```javascript -// Contact is unsubscribed -POST /v1/send { to: "user@example.com", template: "newsletter" } -// → Email NOT sent -``` +Sends regardless of subscription status. Use only for essential emails. -## Transactional templates - -Sends regardless of subscription status. - -**Use for:** Order confirmations, password resets, account alerts. - -**Behavior:** -```javascript -// Contact is unsubscribed -POST /v1/send { to: "user@example.com", template: "receipt" } -// → Email sent -``` - -## Setting template type - -### In dashboard - -**Templates** → Create/Edit → **Type** dropdown - -### Via API - -```bash -curl -X POST {{API_URL}}/templates \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Password Reset", - "type": "TRANSACTIONAL", - "subject": "Reset your password", - "body": "

Click: {{resetLink}}

" - }' -``` - -Type: `MARKETING` or `TRANSACTIONAL` - -## Changing type - -Update anytime: - -```bash -curl -X PATCH {{API_URL}}/templates/template_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{"type": "MARKETING"}' -``` - -**Warning:** Changing transactional → marketing stops sending to unsubscribed contacts. - -## Unsubscribe links - -Marketing templates auto-include unsubscribe link in footer. - -Custom placement: -```html -Unsubscribe -``` - -Transactional templates don't need unsubscribe links. - -## Choosing the right type +## Choosing Ask: "Would user be frustrated if they didn't receive this after unsubscribing?" -**If yes → Transactional** -- Password resets -- Order confirmations -- Account alerts +- **Yes → Transactional** (password resets, order confirmations) +- **No → Marketing** (newsletters, promotions) -**If no → Marketing** -- Newsletters -- Product announcements -- Promotions +## Variables -## Best practices +```html +

Hello {{firstName}}!

+

Your {{plan}} plan renews on {{renewalDate}}.

+``` -**Default to marketing** — Only use transactional for truly necessary emails. - -**Don't abuse transactional** — Sending marketing content via transactional templates violates regulations and damages reputation. - -**Test both states** — Verify behavior with subscribed and unsubscribed contacts. - -## Next steps - -- [Create templates](/guides/templates) -- [Manage subscriptions](/concepts/contacts-and-data) -- [Send emails](/tutorials/first-transactional-email) +**Fallback:** `{{firstName ?? 'there'}}` diff --git a/apps/wiki/content/docs/concepts/troubleshooting.mdx b/apps/wiki/content/docs/concepts/troubleshooting.mdx new file mode 100644 index 0000000..fca3be9 --- /dev/null +++ b/apps/wiki/content/docs/concepts/troubleshooting.mdx @@ -0,0 +1,44 @@ +--- +title: Troubleshooting +description: Common issues and solutions +icon: Bug +--- + +## Authentication + +**Invalid API key:** +- Secret keys start with `sk_`, public keys with `pk_` +- Use `Authorization: Bearer sk_your_secret_key` + +**Wrong key type:** +- Public keys (`pk_`) only work with `/v1/track` +- All other endpoints require secret keys (`sk_`) + +## Emails not sending + +1. **Billing limit reached** — Check Settings → Billing +2. **Template not found** — Verify template ID exists +3. **Contact unsubscribed** — Marketing templates skip unsubscribed contacts + +## Emails going to spam + +1. Set up a custom domain +2. Clean your list (remove bounces) +3. Warm up new domains gradually + +## Workflows not triggering + +1. **Workflow not enabled** — Check toggle is ON +2. **Wrong event name** — Names are case-sensitive +3. **Already entered** — If re-entry disabled, contact can only enter once + +## Rate limiting (429) + +- Wait and retry +- Batch multiple recipients in one request +- Spread requests over time + +## Request IDs + +Every API response includes `X-Request-ID` header. Include this when contacting support. + diff --git a/apps/wiki/content/docs/concepts/webhooks.mdx b/apps/wiki/content/docs/concepts/webhooks.mdx new file mode 100644 index 0000000..f0b38fa --- /dev/null +++ b/apps/wiki/content/docs/concepts/webhooks.mdx @@ -0,0 +1,47 @@ +--- +title: Webhooks +description: Send HTTP requests from workflows +icon: Webhook +--- + +## Overview + +Webhooks send HTTP requests to external services from within workflows. + +## Adding a webhook step + +1. Edit a workflow +2. Add a **Webhook** step +3. Configure URL, method, headers, and body + +## Configuration + +```json +{ + "url": "https://your-api.com/webhook", + "method": "POST", + "headers": { + "Authorization": "Bearer your_api_token" + }, + "body": { + "email": "{{email}}", + "firstName": "{{data.firstName}}" + } +} +``` + +**Methods:** POST, PUT, PATCH, GET, DELETE + +## Variables + +- `{{email}}`, `{{id}}` — Contact fields +- `{{data.fieldName}}` — Custom data +- `{{workflowName}}`, `{{now}}` — Workflow context + +## Error handling + +- Failed webhooks don't block the workflow +- Errors logged in execution details +- 30 second timeout +- No automatic retries + diff --git a/apps/wiki/content/docs/guides/analytics.mdx b/apps/wiki/content/docs/guides/analytics.mdx deleted file mode 100644 index 4ea20a3..0000000 --- a/apps/wiki/content/docs/guides/analytics.mdx +++ /dev/null @@ -1,181 +0,0 @@ ---- -title: Analytics -description: Track and analyze email performance ---- - -## What you can track - -Plunk tracks comprehensive email metrics across all campaigns, workflows, and transactional emails: - -**Delivery metrics:** -- Sent, delivered, bounced - -**Engagement metrics:** -- Opens, clicks, unsubscribes - -**Quality metrics:** -- Open rate, click rate, bounce rate - -## Campaign analytics - -View detailed performance for specific campaigns: - -```bash -curl -X GET {{API_URL}}/campaigns/campaign_id/stats \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Response: - -```json -{ - "totalRecipients": 5000, - "sentCount": 5000, - "deliveredCount": 4980, - "openedCount": 2100, - "clickedCount": 450, - "bouncedCount": 20, - "unsubscribedCount": 8, - "openRate": 0.422, - "clickRate": 0.090, - "bounceRate": 0.004 -} -``` - -**Metrics update in real-time** as recipients engage. - -View detailed analytics in your dashboard to: -- Monitor daily performance trends -- Identify engagement patterns -- Spot deliverability issues -- Compare time periods - -## Understanding metrics - -### Open rate - -**Formula:** (Unique opens / Delivered) × 100 - -**Industry benchmarks:** -- B2B: 15-25% -- B2C: 20-30% -- E-commerce: 15-20% - -**What affects it:** -- Subject line quality -- Sender reputation -- Send timing -- Audience engagement - -### Click rate - -**Formula:** (Unique clicks / Delivered) × 100 - -**Industry benchmarks:** -- B2B: 2-5% -- B2C: 3-7% -- E-commerce: 2-4% - -**What affects it:** -- Content relevance -- Call-to-action clarity -- Email design -- Link placement - -### Bounce rate - -**Formula:** (Bounces / Sent) × 100 - -**Target:** < 2% - -**Types:** -- **Hard bounce** — Invalid email, never retry -- **Soft bounce** — Temporary issue, retry later - -**High bounce rate causes:** -- Outdated email list -- Invalid addresses -- Domain issues - -### Unsubscribe rate - -**Formula:** (Unsubscribes / Delivered) × 100 - -**Target:** < 0.5% - -**High unsubscribe causes:** -- Too frequent emails -- Irrelevant content -- Misleading subject lines -- No segmentation - -## Improving performance - -### Boost open rates - -**Write compelling subject lines:** -- Keep under 50 characters -- Create urgency or curiosity -- Personalize with `{{firstName}}` -- Test different approaches - -**Optimize send timing:** -- Test different days/times -- Segment by timezone -- Avoid weekends (for B2B) -- Consider user behavior - -**Build sender reputation:** -- Use custom domain -- Maintain consistent volume -- Keep bounce rate low -- Avoid spam triggers - -### Increase click rates - -**Clear call-to-action:** -- One primary CTA -- Use buttons, not just links -- Action-oriented text ("Get Started" not "Click Here") -- Make it prominent - -**Relevant content:** -- Segment audience -- Personalize messaging -- Match subject line promise -- Keep it focused - -**Mobile-responsive:** -- Test on mobile devices -- Use large tap targets -- Single column layout -- Readable font sizes - -### Reduce bounce rate - -**Clean your list:** -```javascript -// Remove hard bounces immediately -const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000'); - -for (const event of bounced.data.events) { - if (event.data.bounceType === 'hard') { - await fetch(`{{API_URL}}/contacts/${event.contactId}`, { - method: 'DELETE', - headers: { 'Authorization': `Bearer ${apiKey}` } - }); - } -} -``` - -**Verify emails:** -- Use email verification service -- Double opt-in for signups -- Remove invalid formats -- Re-engage inactive users before removing - -## Next Steps - -- [Set up custom domains](/guides/custom-domains) for better deliverability -- [Build segments](/guides/segments) for targeted campaigns -- [Scale your email](/guides/scaling-email) with best practices diff --git a/apps/wiki/content/docs/guides/billing-limits.mdx b/apps/wiki/content/docs/guides/billing-limits.mdx deleted file mode 100644 index 3b120fc..0000000 --- a/apps/wiki/content/docs/guides/billing-limits.mdx +++ /dev/null @@ -1,115 +0,0 @@ ---- -title: Billing & Usage Limits -description: Control monthly email usage and costs ---- - -## What are billing limits - -Billing limits let you cap monthly email sends by category to control costs. Set maximum emails per month for transactional, campaigns, and workflows separately. - -## Email categories - -Plunk tracks usage across three categories: - -**Transactional** — Emails sent via `/v1/send` API -- Order confirmations, password resets -- Account notifications -- Any direct API sends - -**Campaigns** — One-time broadcast emails -- Newsletters, announcements -- Promotional campaigns -- Marketing blasts - -**Workflows** — Automated sequence emails -- Onboarding flows -- Drip campaigns -- Behavior-triggered emails - -**Note:** The category is determined by how you send (API, campaign, or workflow), not the template type. - -## How limits work - -### Monthly reset - -Usage resets on the 1st of each month (UTC). Starts fresh at 0. - -### Enforcement - -When sending emails: - -- **Under 80%** — Sends normally -- **80-99%** — Sends with warning flag -- **100%+** — Blocked with 429 error - -### Unlimited - -Set limit to unlimited for any category (default for all categories). - -## Manage limits - -You can view and update your billing limits in the dashboard: - -1. Go to **Settings** → **Billing** -2. View current usage for each category -3. Update limits as needed (requires Admin or Owner role) - -## When limit is reached - -### API error - -```json -{ - "code": 429, - "error": "Too Many Requests", - "message": "Monthly limit exceeded for campaigns (50000/50000). Resets on 2025-12-01." -} -``` - -### Handle in code - -```javascript -try { - const response = await fetch('{{API_URL}}/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(emailData) - }); - - if (response.status === 429) { - const error = await response.json(); - - // Option 1: Notify admin - await notifyAdmin(`Limit reached: ${error.message}`); - - // Option 2: Increase limit - await increaseBillingLimit('transactional', 200000); - - // Option 3: Queue for next month - await queueForNextMonth(emailData); - } -} catch (error) { - console.error('Send failed:', error); -} -``` - -## Best practices - -**Monitor usage regularly** — Check dashboard weekly to avoid surprises. - -**Set alerts** — Configure notifications at 80% usage. - -**Plan for growth** — Increase limits before campaigns, not during. - -**Use categories wisely** — Critical transactional emails might need higher limits. - -**Review monthly** — Adjust limits based on actual usage patterns. - -## Next Steps - -- [Track usage analytics](/guides/analytics) -- [Scale email delivery](/guides/scaling-email) -- [Troubleshooting limits](/guides/troubleshooting) diff --git a/apps/wiki/content/docs/guides/campaigns.mdx b/apps/wiki/content/docs/guides/campaigns.mdx deleted file mode 100644 index 4eaf316..0000000 --- a/apps/wiki/content/docs/guides/campaigns.mdx +++ /dev/null @@ -1,247 +0,0 @@ ---- -title: Campaigns -description: Send one-time email broadcasts ---- - -## What are campaigns - -Campaigns are one-time email broadcasts sent to your audience. Use them for: -- Product announcements -- Newsletter distributions -- Seasonal promotions -- Feature launches - -Unlike workflows (automated sequences), campaigns send once to a snapshot of your audience. - -## Creating campaigns - -### In the dashboard - -1. Go to **Campaigns** -2. Click **Create Campaign** -3. Name your campaign -4. Choose your audience -5. Select a template or compose inline -6. Preview and test -7. Send or schedule - -### Via API - -```bash -curl -X POST {{API_URL}}/campaigns \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "March Newsletter", - "subject": "New features this month", - "body": "

What'\''s new

Check out our latest updates...

", - "from": "newsletter@example.com", - "fromName": "Acme Inc", - "audienceType": "ALL", - "status": "DRAFT" - }' -``` - -## Choosing your audience - -### All contacts - -Sends to everyone in your project: - -```json -{ - "audienceType": "ALL" -} -``` - -### Specific segment - -Sends to contacts in a saved segment: - -```json -{ - "audienceType": "SEGMENT", - "segmentId": "premium-users" -} -``` - -### Custom filters - -Sends to contacts matching conditions: - -```json -{ - "audienceType": "FILTERED", - "audienceFilter": { - "operator": "AND", - "conditions": [ - { "field": "data.plan", "operator": "equals", "value": "pro" }, - { "field": "data.lastLoginAt", "operator": "greaterThan", "value": "2024-01-01" } - ] - } -} -``` - -## Subscription handling - -Campaign delivery respects your **template type**: - -**Marketing templates** (default) -- Only sends to subscribed contacts -- Unsubscribed contacts are skipped automatically -- Includes unsubscribe link - -**Transactional templates** -- Sends to all contacts, even if unsubscribed -- Use only for critical business emails -- No unsubscribe link - -Choose template type based on content, not audience size. - -## Campaign states - -**DRAFT** — Being created, can edit freely - -**SCHEDULED** — Queued for future send, can cancel - -**SENDING** — Currently delivering, cannot stop - -**SENT** — Completed successfully - -**CANCELLED** — Scheduled campaign was cancelled - -## Sending campaigns - -### Send immediately - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/send \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Status changes to SENDING, emails deliver within minutes. - -### Schedule for later - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "scheduledAt": "2024-03-20T10:00:00Z" - }' -``` - -Status changes to SCHEDULED. Campaign sends at the specified time. - -### Cancel scheduled campaign - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/cancel \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Only works if status is SCHEDULED. - -## Testing campaigns - -Send a test email before broadcasting: - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/test \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "test@example.com" - }' -``` - -This sends to the test email without affecting campaign status. - -## Campaign analytics - -View campaign performance: - -```bash -curl -X GET {{API_URL}}/campaigns/campaign_id/stats \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns: - -```json -{ - "totalRecipients": 5000, - "sentCount": 5000, - "deliveredCount": 4980, - "openedCount": 2100, - "clickedCount": 450, - "bouncedCount": 20, - "openRate": 0.42, - "clickRate": 0.09 -} -``` - -Metrics update in real-time as recipients engage. - -## Managing campaigns - -### List campaigns - -```bash -curl -X GET {{API_URL}}/campaigns \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Filter by status: - -```bash -curl -X GET "{{API_URL}}/campaigns?status=SENT" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Get campaign details - -```bash -curl -X GET {{API_URL}}/campaigns/campaign_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Update draft campaign - -```bash -curl -X PATCH {{API_URL}}/campaigns/campaign_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Updated name", - "subject": "New subject line" - }' -``` - -Only works for DRAFT campaigns. - -### Duplicate campaign - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Creates a new draft campaign with the same content. - -### Delete campaign - -```bash -curl -X DELETE {{API_URL}}/campaigns/campaign_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Can only delete DRAFT or CANCELLED campaigns. - -## Next Steps - -- [Build automated workflows](/guides/workflows) -- [Create dynamic segments](/guides/segments) -- [Track campaign analytics](/guides/analytics) -- [Set up custom domains](/guides/custom-domains) diff --git a/apps/wiki/content/docs/guides/contact-data.mdx b/apps/wiki/content/docs/guides/contact-data.mdx deleted file mode 100644 index 3ed4615..0000000 --- a/apps/wiki/content/docs/guides/contact-data.mdx +++ /dev/null @@ -1,205 +0,0 @@ ---- -title: Working with Contact Data -description: Use persistent and temporary contact data for personalized emails ---- - -## Contact Data Basics - -Every contact has: -- **email**: Required, unique identifier -- **subscribed**: Boolean for subscription status -- **data**: JSON object for custom fields - -```javascript -{ - "email": "user@example.com", - "subscribed": true, - "data": { - "firstName": "Jane", - "plan": "professional", - "signupDate": "2024-03-15" - } -} -``` - -## Persistent vs. Temporary Data - -When sending emails, you can pass data that either saves to the contact or is used only for that email. - -### Persistent Data (Default) - -```javascript -fetch('/v1/send', { - method: 'POST', - body: JSON.stringify({ - to: 'user@example.com', - subject: 'Welcome', - body: '

Hi {'{{firstName}}'}!

', - data: { - firstName: 'John' // Saved to contact.data.firstName - } - }) -}); -``` - -### Temporary Data (Non-Persistent) - -```javascript -fetch('/v1/send', { - method: 'POST', - body: JSON.stringify({ - to: 'user@example.com', - subject: 'Password Reset', - body: '

Your code: {'{{resetCode}}'}

', - data: { - resetCode: { - value: 'ABC123', - persistent: false // NOT saved to contact - } - } - }) -}); -``` - -**Use temporary data for**: -- Password reset codes -- One-time verification tokens -- Session-specific information -- Temporary discount codes - -## Template Variables - -Use `{'{{fieldName}}'}` to insert contact data into emails. - -### Basic Variables - -```html -

Hello {'{{firstName}}'}!

-

Your plan: {'{{plan}}'}

-``` - -### Fallback Values - -Provide defaults when data might be missing: - -```html -

Hello {'{{firstName ?? \'there\'}}'}!

-

Plan: {'{{plan ?? \'Free\'}}'}

-``` - -### Reserved Fields - -Two fields are always available: - -```html -

Contact ID: {'{{plunk_id}}'}

-

Email: {'{{plunk_email}}'}

-``` - -## Data Merging - -Updates merge with existing data: - -```javascript -// Contact has: { firstName: 'John', plan: 'free' } - -// Update with: -{ data: { lastName: 'Doe', plan: 'pro' } } - -// Result: { firstName: 'John', lastName: 'Doe', plan: 'pro' } -``` - -## Best Practices - -### Keep Data Flat - -```javascript -// Good -{ - "firstName": "Jane", - "plan": "pro", - "mrr": 99 -} - -// Avoid nesting (harder to use in templates) -{ - "user": { - "profile": { - "name": "Jane" - } - } -} -``` - -### Use Consistent Naming - -Pick a style and stick to it: - -```javascript -// camelCase (recommended) -{ "firstName": "Jane", "lastLogin": "2024-03-15" } - -// or snake_case -{ "first_name": "Jane", "last_login": "2024-03-15" } -``` - -### Store Dates as ISO Strings - -```javascript -// Good (filterable, sortable) -{ "signupDate": "2024-03-15T10:30:00Z" } - -// Avoid -{ "signupDate": "March 15, 2024" } -``` - -## Discovering Available Fields - -Get all fields across your contacts: - -```bash -curl -X GET "{{API_URL}}/contacts/fields" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Response: -```json -{ - "success": true, - "data": { - "fields": [ - "email", - "subscribed", - "firstName", - "plan", - "signupDate" - ], - "count": 5 - } -} -``` - -Get unique values for a field: - -```bash -curl -X GET "{{API_URL}}/contacts/fields/data.plan/values" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Response: -```json -{ - "success": true, - "data": { - "field": "data.plan", - "values": ["free", "professional", "enterprise"], - "count": 3 - } -} -``` - -## Next Steps - -- [Send personalized emails](/guides/templates) -- [Create segments](/guides/segments) based on contact data -- [Track events](/guides/events) to enrich contact profiles diff --git a/apps/wiki/content/docs/guides/contacts.mdx b/apps/wiki/content/docs/guides/contacts.mdx deleted file mode 100644 index 13dee79..0000000 --- a/apps/wiki/content/docs/guides/contacts.mdx +++ /dev/null @@ -1,436 +0,0 @@ ---- -title: Contacts -description: Manage your audience at scale ---- - -## What are contacts - -Contacts are people in your audience. Each contact has an email address, subscription status, and custom data fields you define. Use contacts to personalize emails, build segments, and track engagement. - -## Creating contacts - -### Add a single contact - -```bash -curl -X POST {{API_URL}}/contacts \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "subscribed": true, - "data": { - "firstName": "Sarah", - "plan": "pro", - "signupDate": "2024-03-15" - } - }' -``` - -### Automatic upsert - -If the email already exists, the contact is updated instead of creating a duplicate: - -```javascript -// First call - creates contact -POST /contacts { email: 'user@example.com', data: { plan: 'free' } } - -// Second call - updates same contact -POST /contacts { email: 'user@example.com', data: { plan: 'pro' } } - -// Result: One contact with plan: 'pro' -``` - -This is useful when syncing user data from your application. - -## Contact data fields - -Store custom data in the `data` field. Use it for: - -- User profile (name, company, role) -- Subscription info (plan, MRR, renewal date) -- Behavior tracking (last login, feature usage) -- Preferences (newsletter, notifications) - -**Example:** - -```json -{ - "email": "user@example.com", - "subscribed": true, - "data": { - "firstName": "Sarah", - "lastName": "Chen", - "company": "Acme Inc", - "plan": "premium", - "mrr": 99, - "lastLoginAt": "2024-03-15T10:30:00Z", - "preferences": { - "newsletter": true, - "productUpdates": false - } - } -} -``` - -### Best practices - -**Use consistent naming** — Pick camelCase or snake_case and stick with it. - -**Store dates as ISO strings** — `"2024-03-15T10:30:00Z"` enables date range filtering in segments. - -**Keep it relatively flat** — Nested objects work, but flat structures are easier to query in segments. - -**Use numbers for numeric data** — Store `99` not `"99"` to enable greater than/less than comparisons. - -## Using contact data in emails - -### Template variables - -Access contact data in email templates using `{{variableName}}` syntax: - -```html -

Hello {{firstName}}!

-

Your {{plan}} plan renews on {{renewalDate}}.

-

Total: ${{mrr}}

-``` - -When sending, contact data automatically populates variables: - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "subject": "Renewal reminder", - "body": "

Hi {{firstName}}, your {{plan}} plan renews soon.

" - }' -``` - -The `firstName` and `plan` values come from the contact's `data` field. - -### Fallback values - -Provide defaults when data might be missing: - -```html -

Hello {{firstName ?? 'there'}}!

-

Plan: {{plan ?? 'Free'}}

-``` - -If `firstName` is not set, displays "Hello there!" instead of blank. - -### Passing additional data - -Send extra data for a specific email without saving it to the contact: - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "subject": "Your verification code", - "body": "

Your code: {{verificationCode}}

", - "data": { - "verificationCode": "ABC123" - } - }' -``` - -The `verificationCode` is used in the email but not saved to the contact. This is useful for: -- One-time codes (password reset, verification) -- Session-specific data -- Temporary discount codes -- Order-specific details - -### Reserved variables - -These are always available in templates: - -- `{{email}}` — Contact email address -- `{{id}}` — Contact ID - -Example: -```html -

Your account: {{email}}

-

Manage preferences

-``` - -## Listing contacts - -### Get all contacts - -```bash -curl -X GET "{{API_URL}}/contacts?limit=50" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns: - -```json -{ - "success": true, - "data": { - "items": [...], - "nextCursor": "abc123", - "hasMore": true, - "total": 10000 - } -} -``` - -### Pagination - -For large lists, use cursor-based pagination: - -```javascript -let allContacts = []; -let cursor = null; - -do { - const params = new URLSearchParams({ limit: 100 }); - if (cursor) params.append('cursor', cursor); - - const response = await fetch(`{{API_URL}}/contacts?${params}`, { - headers: { 'Authorization': `Bearer ${PLUNK_SECRET_KEY}` } - }); - - const { data } = await response.json(); - allContacts.push(...data.items); - cursor = data.nextCursor; -} while (cursor); -``` - -### Filter by subscription - -```bash -# Only subscribed -curl -X GET "{{API_URL}}/contacts?subscribed=true" \ - -H "Authorization: Bearer sk_your_secret_key" - -# Only unsubscribed -curl -X GET "{{API_URL}}/contacts?subscribed=false" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Search by email - -```bash -curl -X GET "{{API_URL}}/contacts?search=sarah" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Searches for emails containing "sarah". - -## Getting a contact - -### By ID - -```bash -curl -X GET {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -## Updating contacts - -### Update contact data - -```bash -curl -X PATCH {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "subscribed": true, - "data": { - "plan": "premium", - "mrr": 99 - } - }' -``` - -### Data merging - -Updates merge with existing data: - -```javascript -// Current contact data -{ "firstName": "Sarah", "company": "Acme" } - -// Update with -{ "lastName": "Chen", "plan": "pro" } - -// Result -{ "firstName": "Sarah", "company": "Acme", "lastName": "Chen", "plan": "pro" } -``` - -To remove a field, set it to `null`. - -### Change subscription status - -```bash -curl -X PATCH {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{"subscribed": false}' -``` - -**Marketing templates** only send to subscribed contacts. **Transactional templates** send to everyone, regardless of subscription status. - -## Deleting contacts - -```bash -curl -X DELETE {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -**Warning:** Deletion is permanent. Consider unsubscribing instead of deleting. - -## Bulk operations - -### Import from CSV - -Prepare a CSV file: - -```csv -email,firstName,lastName,plan -sarah@example.com,Sarah,Chen,pro -john@example.com,John,Doe,free -``` - -Upload via dashboard: -1. Go to **Contacts** -2. Click **Import CSV** -3. Upload file -4. Map columns -5. Set default subscription status -6. Import - -The import runs in the background. You'll receive a summary when complete. - -## Available fields - -### Get all custom fields - -See what data fields your contacts have: - -```bash -curl -X GET {{API_URL}}/contacts/fields \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns unique field names across all contacts: - -```json -{ - "fields": [ - "firstName", - "lastName", - "company", - "plan", - "mrr", - "signupDate" - ] -} -``` - -### Get field values - -See all unique values for a specific field: - -```bash -curl -X GET {{API_URL}}/contacts/fields/plan/values \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns: - -```json -{ - "values": ["free", "pro", "premium", "enterprise"] -} -``` - -Useful for building segment filters and understanding your data. - -## Syncing with your app - -Keep contacts in sync with your user database: - -```javascript -// When user signs up -async function onUserSignup(user) { - await fetch('{{API_URL}}/contacts', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: user.email, - subscribed: true, - data: { - firstName: user.firstName, - lastName: user.lastName, - signupDate: new Date().toISOString() - } - }) - }); -} - -// When user updates profile -async function onUserUpdate(user) { - await fetch(`{{API_URL}}/contacts/${user.contactId}`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - data: { - firstName: user.firstName, - lastName: user.lastName, - company: user.company - } - }) - }); -} - -// When user subscribes to plan -async function onSubscriptionChange(user, plan, mrr) { - await fetch(`{{API_URL}}/contacts/${user.contactId}`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - data: { - plan, - mrr, - subscriptionDate: new Date().toISOString() - } - }) - }); -} -``` - -## Best practices - -**Sync critical data only** — Don't sync every field. Focus on data used in segments, workflows, and personalization. - -**Use webhooks for real-time sync** — Update contacts immediately when user data changes. - -**Track subscription separately** — Use the `subscribed` field for email preferences, not app subscription status. - -**Clean your list regularly** — Remove or unsubscribe bounced and inactive contacts. - -**Respect opt-outs** — When users unsubscribe, update immediately. Don't re-subscribe them automatically. - -**Test with real emails** — Use your own email addresses to test the contact experience. - -## Next Steps - -- [Build segments](/guides/segments) to group contacts -- [Send campaigns](/guides/campaigns) to your contacts -- [Track events](/guides/events) to update contact data automatically diff --git a/apps/wiki/content/docs/guides/custom-domains.mdx b/apps/wiki/content/docs/guides/custom-domains.mdx deleted file mode 100644 index 06d5692..0000000 --- a/apps/wiki/content/docs/guides/custom-domains.mdx +++ /dev/null @@ -1,209 +0,0 @@ ---- -title: Custom Domains -description: Send emails from your own domain ---- - -## Why use custom domains - -Sending from your own domain (e.g., `hello@yourdomain.com`) instead of a shared domain: - -- **Better deliverability** — Email providers trust emails from verified domains -- **Brand consistency** — Recipients see your brand, not Plunk -- **Higher trust** — Your domain builds its own sender reputation -- **Professional appearance** — Custom addresses look more legitimate - -## Requirements - -- **Domain ownership** — You own or control the domain -- **DNS access** — Ability to add DNS records -- **Verification** — Add DKIM records to prove ownership - -## Adding a domain - -1. Go to **Settings > Domains** -2. Click **Add Domain** -3. Enter your domain (e.g., `yourdomain.com`) -4. Copy the provided DNS records - -## DNS configuration - -After adding your domain, you'll receive 3 DKIM tokens. Add them as CNAME records to your DNS. - -### Common DNS providers - -#### Cloudflare - -1. Log into Cloudflare -2. Select your domain -3. Go to **DNS > Records** -4. Click **Add record** -5. Select **CNAME** type -6. Paste name and value from Plunk -7. Click **Save** -8. Repeat for all 3 records - -#### Namecheap - -1. Log into Namecheap -2. Go to **Domain List** -3. Click **Manage** next to your domain -4. Select **Advanced DNS** -5. Click **Add New Record** -6. Choose **CNAME Record** -7. Enter host and value -8. Repeat for all 3 records - -#### GoDaddy - -1. Log into GoDaddy -2. Go to **My Products** -3. Click **DNS** next to your domain -4. Click **Add** under Records -5. Select **CNAME** type -6. Enter name and value -7. Repeat for all 3 records - -#### Route 53 (AWS) - -1. Open Route 53 console -2. Select your hosted zone -3. Click **Create record** -4. Enter record name -5. Select **CNAME** type -6. Paste value -7. Create record -8. Repeat for all 3 records - -## Verification - -### Automatic verification - -Plunk checks DNS records every 5 minutes automatically. Verification typically completes within 10-30 minutes after adding DNS records. - -**Note:** DNS propagation can take up to 48 hours, though it's usually much faster. - -Check verification status in your dashboard at **Settings > Domains**. - -## Using your domain - -Once verified, specify your domain in the `from` field: - -### In transactional emails - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "customer@example.com", - "from": "orders@yourdomain.com", - "fromName": "Your Company", - "subject": "Order confirmed", - "body": "

Your order has been confirmed.

" - }' -``` - -### In templates - -Set default from address in template: - -```bash -curl -X POST {{API_URL}}/templates \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Order Confirmation", - "subject": "Order #{{orderNumber}} confirmed", - "body": "...", - "from": "orders@yourdomain.com", - "fromName": "Your Company", - "type": "TRANSACTIONAL" - }' -``` - -### In campaigns - -Campaigns use the template's from address, or you can override: - -```bash -curl -X POST {{API_URL}}/campaigns \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Newsletter", - "templateId": "template_id", - "from": "newsletter@yourdomain.com", - "audienceType": "ALL" - }' -``` - -## Managing domains - -### Remove domain - -Go to **Settings > Domains**, select the domain, and click **Remove**. - -**Warning:** Emails using this domain will fail to send after removal. - -## Troubleshooting - -### Domain won't verify - -**1. Check DNS records are correct** - -Verify records are added exactly as provided - -**2. Wait for propagation** - -DNS changes can take up to 48 hours to propagate globally. Check periodically. - -**3. Remove conflicting records** - -If you previously used another email service, remove their DKIM records to avoid conflicts. - -**4. Check for typos** - -Ensure record names and values match exactly. Common issues: -- Extra spaces in values -- Missing dots in record names -- Wrong subdomain - -### Emails not sending from domain - -**1. Verify domain is verified** - Check status in **Settings > Domains**. - -**2. Use correct email format** - -Must be `username@yourdomain.com`, not `@subdomain.yourdomain.com`. - -**3. Check sender reputation** - -New domains have no reputation. Start with small volumes and gradually increase. - -### Emails going to spam - -After adding custom domain: - -**1. Warm up your domain** — See [Scaling Email](/guides/scaling-email) - -**2. Monitor deliverability** — Check [Analytics](/guides/analytics) for bounce/complaint rates - -**3. Clean your list** — Remove bounced addresses immediately - -## Best practices - -**Start small** — Send to engaged users first to build reputation. - -**Monitor metrics** — Watch bounce and complaint rates closely. - -**Use subdomains** — Consider `mail.yourdomain.com` for email to separate from main domain reputation. - -**Keep DNS records** — Don't remove DKIM records even if verification is complete. - -**Test thoroughly** — Send test emails to various providers (Gmail, Outlook, Yahoo). - -## Next Steps - -- [Scale email delivery](/guides/scaling-email) with your custom domain -- [Monitor analytics](/guides/analytics) for domain performance -- [Troubleshoot issues](/guides/troubleshooting) if problems arise diff --git a/apps/wiki/content/docs/guides/email-attachments.mdx b/apps/wiki/content/docs/guides/email-attachments.mdx deleted file mode 100644 index 6b6e61d..0000000 --- a/apps/wiki/content/docs/guides/email-attachments.mdx +++ /dev/null @@ -1,385 +0,0 @@ ---- -title: Email Attachments -description: Send emails with file attachments via API or SMTP ---- - -## Overview - -Plunk supports sending emails with file attachments through both the HTTP API and SMTP relay. You can attach documents, images, PDFs, and other files to your transactional emails. - -## Limits - -- **Maximum attachments**: 10 per email -- **Total size limit**: 10MB (combined size of all attachments) -- **Supported formats**: Any file type (PDF, images, documents, etc.) - -## API Usage - -### Basic Example - -Send an email with a single PDF attachment: - -```bash -curl -X POST https://api.useplunk.com/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "subject": "Your Invoice", - "body": "

Invoice Attached

Please find your invoice attached.

", - "attachments": [ - { - "filename": "invoice.pdf", - "content": "JVBERi0xLjQKJeLjz9MKMSAwIG9iago8PC9UeXBlL...", - "contentType": "application/pdf" - } - ] - }' -``` - -### Multiple Attachments - -Send multiple files in a single email: - -```json -{ - "to": "user@example.com", - "subject": "Monthly Reports", - "body": "

Please find this month's reports attached.

", - "attachments": [ - { - "filename": "sales-report.pdf", - "content": "JVBERi0xLjQK...", - "contentType": "application/pdf" - }, - { - "filename": "logo.png", - "content": "iVBORw0KGgo...", - "contentType": "image/png" - }, - { - "filename": "data.csv", - "content": "TmFtZSxFbWFp...", - "contentType": "text/csv" - } - ] -} -``` - -## Attachment Format - -Each attachment object requires three fields: - -### filename - -- **Type**: String -- **Max length**: 255 characters -- **Description**: The name of the file as it will appear to recipients -- **Example**: `"invoice-2024.pdf"` - -### content - -- **Type**: String (Base64 encoded) -- **Description**: The file content encoded in Base64 format -- **Example**: `"JVBERi0xLjQKJeLjz9MK..."` - -### contentType - -- **Type**: String (MIME type) -- **Max length**: 255 characters -- **Description**: The MIME type of the file -- **Examples**: - - PDF: `application/pdf` - - PNG image: `image/png` - - JPEG image: `image/jpeg` - - Word document: `application/vnd.openxmlformats-officedocument.wordprocessingml.document` - - Excel: `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` - - CSV: `text/csv` - - ZIP: `application/zip` - -## Base64 Encoding - -Attachments must be Base64 encoded before sending. Here are examples in different languages: - -### JavaScript/Node.js - -```javascript -import fs from 'fs'; - -// Read file and convert to Base64 -const fileBuffer = fs.readFileSync('invoice.pdf'); -const base64Content = fileBuffer.toString('base64'); - -// Send email with attachment -await fetch('https://api.useplunk.com/v1/send', { - method: 'POST', - headers: { - 'Authorization': 'Bearer sk_your_secret_key', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - to: 'user@example.com', - subject: 'Invoice', - body: '

Your invoice is attached.

', - attachments: [{ - filename: 'invoice.pdf', - content: base64Content, - contentType: 'application/pdf' - }] - }) -}); -``` - -### Python - -```python -import base64 -import requests - -# Read and encode file -with open('invoice.pdf', 'rb') as file: - base64_content = base64.b64encode(file.read()).decode('utf-8') - -# Send email -response = requests.post( - 'https://api.useplunk.com/v1/send', - headers={ - 'Authorization': 'Bearer sk_your_secret_key', - 'Content-Type': 'application/json' - }, - json={ - 'to': 'user@example.com', - 'subject': 'Invoice', - 'body': '

Your invoice is attached.

', - 'attachments': [{ - 'filename': 'invoice.pdf', - 'content': base64_content, - 'contentType': 'application/pdf' - }] - } -) -``` - -### PHP - -```php - 'user@example.com', - 'subject' => 'Invoice', - 'body' => '

Your invoice is attached.

', - 'attachments' => [[ - 'filename' => 'invoice.pdf', - 'content' => $base64Content, - 'contentType' => 'application/pdf' - ]] -])); - -$response = curl_exec($ch); -curl_close($ch); -``` - -## SMTP Usage - -When using the SMTP relay, attachments are automatically parsed from the MIME multipart message and forwarded to the API. - -### Standard Email Clients - -Configure your email client with Plunk SMTP settings and attach files normally: - -- **SMTP Server**: `smtp.yourdomain.com` -- **Port**: 587 (STARTTLS) or 465 (SSL/TLS) -- **Username**: `plunk` -- **Password**: Your Plunk API secret key - -Attachments added through your email client will be automatically included. - -### Programmatic SMTP - -Using nodemailer (Node.js): - -```javascript -import nodemailer from 'nodemailer'; - -const transporter = nodemailer.createTransport({ - host: 'smtp.yourdomain.com', - port: 587, - secure: false, // Use STARTTLS - auth: { - user: 'plunk', - pass: 'sk_your_secret_key' - } -}); - -await transporter.sendMail({ - from: 'sender@yourdomain.com', - to: 'recipient@example.com', - subject: 'Invoice', - html: '

Your invoice is attached.

', - attachments: [ - { - filename: 'invoice.pdf', - path: '/path/to/invoice.pdf' - } - ] -}); -``` - -## Common MIME Types - -| File Type | MIME Type | -|-----------|-----------| -| PDF | `application/pdf` | -| PNG | `image/png` | -| JPEG | `image/jpeg` | -| GIF | `image/gif` | -| Word (.docx) | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | -| Word (.doc) | `application/msword` | -| Excel (.xlsx) | `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet` | -| Excel (.xls) | `application/vnd.ms-excel` | -| CSV | `text/csv` | -| Plain text | `text/plain` | -| HTML | `text/html` | -| ZIP | `application/zip` | -| JSON | `application/json` | -| XML | `application/xml` | - -## Best Practices - -### Size Optimization - -- **Compress files**: Use ZIP compression for large files -- **Optimize images**: Reduce image dimensions and quality before attaching -- **Use links for large files**: For files >5MB, consider uploading to cloud storage and sending a download link instead - -### Security - -- **Scan for malware**: Ensure files are virus-free before sending -- **Avoid executable files**: Don't attach .exe, .bat, .sh files (often blocked by email providers) -- **Use password protection**: For sensitive documents, password-protect files and send password separately - -### Deliverability - -- **Mind the size**: Smaller emails have better deliverability -- **Avoid spam triggers**: Don't attach executable files or suspicious content -- **Test first**: Send test emails to verify attachments arrive correctly - -## Troubleshooting - -### Attachment Too Large - -**Error**: `Total attachment size must not exceed 10MB` - -**Solution**: -- Reduce file sizes -- Compress files -- Split into multiple emails -- Use cloud storage links instead - -### Invalid Base64 - -**Error**: `Invalid attachment content - must be base64 encoded` - -**Solution**: -- Ensure file is properly base64 encoded -- Don't include line breaks in base64 string (or use standard base64 encoding) -- Verify encoding matches content (binary files need binary encoding) - -### Wrong Content Type - -**Issue**: Attachments don't open correctly - -**Solution**: -- Use correct MIME type for file format -- Verify file extension matches content type -- Test with common email clients - -### Missing Attachment - -**Issue**: Email sends but attachment missing - -**Solution**: -- Check attachment array is properly formatted -- Verify all required fields (filename, content, contentType) -- Check email provider limits (some block certain types) -- Review AWS SES sending logs - -## Examples by Use Case - -### Invoice Email - -```json -{ - "to": "customer@example.com", - "subject": "Invoice #12345", - "body": "

Thank you for your purchase!

Your invoice is attached.

", - "attachments": [{ - "filename": "invoice-12345.pdf", - "content": "JVBERi0xLjQK...", - "contentType": "application/pdf" - }] -} -``` - -### Report with Charts - -```json -{ - "to": "manager@company.com", - "subject": "Weekly Analytics Report", - "body": "

Weekly Report

See attached for details.

", - "attachments": [ - { - "filename": "analytics-report.pdf", - "content": "JVBERi0xLjQK...", - "contentType": "application/pdf" - }, - { - "filename": "sales-chart.png", - "content": "iVBORw0KGgo...", - "contentType": "image/png" - } - ] -} -``` - -### Welcome Kit - -```json -{ - "to": "newuser@example.com", - "subject": "Welcome to Our Service!", - "body": "

Welcome!

Here's everything you need to get started.

", - "attachments": [ - { - "filename": "getting-started-guide.pdf", - "content": "JVBERi0xLjQK...", - "contentType": "application/pdf" - }, - { - "filename": "sample-data.csv", - "content": "TmFtZSxFbWFp...", - "contentType": "text/csv" - } - ] -} -``` - -## Next Steps - -- [Send your first email](/getting-started/quick-start) -- [SMTP relay setup](/self-hosting/introduction) -- [Email templates](/guides/templates) diff --git a/apps/wiki/content/docs/guides/events.mdx b/apps/wiki/content/docs/guides/events.mdx deleted file mode 100644 index 8e3480e..0000000 --- a/apps/wiki/content/docs/guides/events.mdx +++ /dev/null @@ -1,223 +0,0 @@ ---- -title: Events -description: Track user actions and behavior ---- - -## What are events - -Events track user actions in your application. Use them to: -- Trigger automated workflows -- Build behavior-based segments -- Analyze user engagement -- Track conversion funnels - -Common events: signups, purchases, logins, feature usage, page views. - -## Tracking events - -Use your **public key** for event tracking: - -```javascript -fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': 'Bearer pk_your_public_key', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - email: 'user@example.com', - event: 'button_clicked', - data: { - button: 'signup', - page: '/pricing' - } - }) -}); -``` - -This creates or updates the contact and tracks the event. - -### Persistent vs. Non-Persistent Data - -Event data can be either **persistent** (saved to contact) or **non-persistent** (available only to workflows): - -**Simple values are persistent** — Saved to contact profile: -```javascript -{ - email: 'user@example.com', - event: 'subscription_created', - data: { - plan: 'premium', // Saved to contact.data.plan - mrr: 99.00 // Saved to contact.data.mrr - } -} -``` - -**Non-persistent values** — Available only to triggered workflows: -```javascript -{ - email: 'user@example.com', - event: 'order_placed', - data: { - totalSpent: 299.99, // Persistent - saved to contact - orderId: {value: 'order-12345', persistent: false}, // Non-persistent - workflows only - receiptUrl: {value: 'https://...', persistent: false} // Non-persistent - workflows only - } -} -``` - -**Why use non-persistent data?** -- Temporary tokens/codes (password reset, verification) -- One-time URLs or session data -- Data that shouldn't pollute contact profiles -- Information needed only for a specific workflow - -Non-persistent data is available throughout the entire workflow execution but never stored on the contact record. - -## Event structure - -Each event stores: - -```json -{ - "id": "evt_abc123", - "name": "purchase", - "contactId": "contact_xyz", - "data": { - "product": "Premium Plan", - "amount": 99.00 - }, - "createdAt": "2024-03-15T10:30:00Z" -} -``` - -## Using events -You can use events to trigger workflows, create segments, and analyze user behavior. - -## Common event patterns - -### Lifecycle events - -```javascript -// User signs up -track({ email, event: 'signed_up', data: { source: 'homepage' } }); - -// User activates account -track({ email, event: 'account_activated' }); - -// User completes onboarding -track({ email, event: 'onboarding_completed', data: { steps: 5 } }); -``` - -### Commerce events - -```javascript -// Add to cart -track({ email, event: 'cart_added', data: { productId: '123', price: 49 } }); - -// Purchase -track({ email, event: 'purchase', data: { orderId: '456', total: 99 } }); - -// Subscription created -track({ email, event: 'subscription_created', data: { plan: 'pro', mrr: 29 } }); -``` - -### Engagement events - -```javascript -// Feature used -track({ email, event: 'feature_used', data: { feature: 'export' } }); - -// Page viewed -track({ email, event: 'page_view', data: { path: '/dashboard' } }); - -// Login -track({ email, event: 'logged_in' }); -``` - -### Automatic events - -Plunk sends these automatically: - -**Email events:** -- `email.sent` — Email delivered to inbox -- `email.opened` — Email opened (first time) -- `email.clicked` — Link clicked in email -- `email.bounced` — Email bounced -- `email.complained` — Spam complaint - -**Segment events** (if membership tracking enabled): -- `segment.entered` — Contact joined segment -- `segment.exited` — Contact left segment - -## Event naming conventions - -**Use lowercase with underscores:** -``` -✓ user_signed_up -✓ purchase_completed -✗ UserSignedUp -✗ purchaseCompleted -``` - -**Be specific but concise:** -``` -✓ trial_started -✓ subscription_cancelled -✗ user_started_a_trial -✗ sub_cancel -``` - -**Group related events:** -``` -user_signed_up -user_logged_in -user_deleted_account - -subscription_created -subscription_renewed -subscription_cancelled -``` - -## Managing events - -### List events - -```bash -curl -X GET "{{API_URL}}/events?limit=100" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### List unique event names - -```bash -curl -X GET {{API_URL}}/events/names \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns all event names tracked in your project. - -### Get events for a contact - -```bash -curl -X GET {{API_URL}}/events?contactId=contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -## Best practices - -**Track meaningful actions** — Focus on events that indicate intent or value (signups, purchases, key features). - -**Include context** — Add relevant data to understand the event better (product, amount, source). - -**Be consistent** — Use the same event names and data structure across your application. - -**Don't over-track** — Tracking every click creates noise. Focus on conversion events and key milestones. - -**Test your tracking** — Verify events appear in dashboard before building workflows around them. - -## What's next - -- [Build workflows](/guides/workflows) triggered by events -- [Create segments](/guides/segments) based on event data -- [Analyze events](/guides/analytics) to understand user behavior diff --git a/apps/wiki/content/docs/guides/meta.json b/apps/wiki/content/docs/guides/meta.json deleted file mode 100644 index cd43e7a..0000000 --- a/apps/wiki/content/docs/guides/meta.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "title": "Guides", - "pages": [ - "contacts", - "templates", - "campaigns", - "segments", - "workflows", - "events", - "webhooks", - "analytics", - "custom-domains", - "billing-limits", - "scaling-email", - "troubleshooting" - ] -} diff --git a/apps/wiki/content/docs/guides/request-ids.mdx b/apps/wiki/content/docs/guides/request-ids.mdx deleted file mode 100644 index 72e9e3a..0000000 --- a/apps/wiki/content/docs/guides/request-ids.mdx +++ /dev/null @@ -1,307 +0,0 @@ ---- -title: Request IDs & Debugging -description: How to use request IDs for debugging and tracing API requests ---- - -## Overview - -Every API request to Plunk receives a unique request ID that follows the request through the entire system. Request IDs are essential for debugging, support, and monitoring. - -## What are Request IDs? - -A request ID is a UUID (e.g., `f47ac10b-58cc-4372-a567-0e02b2c3d479`) that: -- Is generated for every API request -- Appears in all related log entries -- Is included in both success and error responses -- Can be used to trace requests across services - -## Where to Find Request IDs - -### In API Responses - -**Error responses** (in the `error.requestId` field): -```json -{ - "success": false, - "error": { - "code": "VALIDATION_ERROR", - "message": "Request validation failed", - "requestId": "f47ac10b-58cc-4372-a567-0e02b2c3d479", - ... - } -} -``` - -**Response headers** (always present, even on success): -```bash -X-Request-ID: f47ac10b-58cc-4372-a567-0e02b2c3d479 -``` - -### In Your Application - -You can capture and log request IDs for correlation: - -```javascript -const response = await fetch('https://api.useplunk.com/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ to, subject, body }) -}); - -// Get request ID from response header -const requestId = response.headers.get('X-Request-ID'); - -// Log it for correlation -console.log(`[${requestId}] Email send request initiated`); - -const data = await response.json(); -if (!data.success) { - // Request ID is also in error response - console.error(`[${data.error.requestId}] Error:`, data.error.message); -} -``` - -## Database Request Logging - -In addition to console/file logs, Plunk stores all API requests in the database with their request IDs. This provides: - -- **Historical audit trail** - See all API calls made to your project -- **Analytics** - Analyze API usage patterns, error rates, popular endpoints -- **User-facing logs** - Display API request history in your dashboard -- **Long-term debugging** - Investigate issues that occurred days or weeks ago -- **Compliance** - Meet audit requirements for API access logs - -### Database Schema - -Each request is stored with: -- Request ID (primary key) -- HTTP method and path -- Status code and response time -- Project ID and user ID (if authenticated) -- IP address and user agent -- Error code and message (if failed) -- Request/response sizes -- Timestamp - -### Retention Policy - -API request logs are retained for **30 days** by default. A background job runs daily at 3 AM to delete older logs. This prevents unbounded table growth while maintaining recent history for debugging. - -You can query your request logs via SQL if self-hosting: - -```sql --- Find all failed requests in the last 24 hours -SELECT * FROM api_requests -WHERE "statusCode" >= 400 -AND "createdAt" > NOW() - INTERVAL '24 hours' -ORDER BY "createdAt" DESC; - --- Find all requests for a specific project -SELECT * FROM api_requests -WHERE "projectId" = 'prj_abc123' -ORDER BY "createdAt" DESC -LIMIT 100; - --- Analyze error rates by endpoint -SELECT - path, - COUNT(*) as total_requests, - COUNT(*) FILTER (WHERE "statusCode" >= 400) as errors, - ROUND(100.0 * COUNT(*) FILTER (WHERE "statusCode" >= 400) / COUNT(*), 2) as error_rate_pct -FROM api_requests -WHERE "createdAt" > NOW() - INTERVAL '7 days' -GROUP BY path -ORDER BY error_rate_pct DESC; -``` - -## How Request IDs Help with Debugging - -### Example Scenario - -You send an email via the API and receive an error. Here's how request IDs help: - -**1. Your application receives an error:** -```json -{ - "success": false, - "error": { - "code": "TEMPLATE_NOT_FOUND", - "message": "Template with ID \"tpl_abc123\" was not found", - "requestId": "a1b2c3d4-e5f6-7890-gh12-i34567890jkl", - ... - } -} -``` - -**2. You contact support with the request ID** - -**3. We search our logs for that request ID and see:** - -``` -[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → Request received - └─ authType: apiKey - └─ projectId: prj_xyz789 - └─ ip: 192.168.1.1 - -[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Looking up template: tpl_abc123 - └─ projectId: prj_xyz789 - -[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] Template not found - └─ errorCode: TEMPLATE_NOT_FOUND - └─ statusCode: 404 - -[a1b2c3d4-e5f6-7890-gh12-i34567890jkl] POST /v1/send → 404 (45ms) -``` - -From this, we can immediately see: -- You're authenticated correctly (authType: apiKey) -- The template ID doesn't exist in your project -- The request took 45ms to process -- No database errors or system issues - -**Result:** We can quickly tell you "That template doesn't exist in your project" without back-and-forth debugging. - -## Using Request IDs in Self-Hosted Deployments - -If you're self-hosting Plunk, you can use request IDs to debug issues in your own logs. - -### Searching Logs - -**With Docker logs:** -```bash -# Find all logs for a specific request -docker logs plunk-api 2>&1 | grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" -``` - -**With standard logs:** -```bash -# Search application logs -grep "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log - -# Search with context (10 lines before and after) -grep -C 10 "a1b2c3d4-e5f6-7890-gh12-i34567890jkl" /var/log/plunk/api.log -``` - -### Log Structure - -Every log entry includes the request ID in brackets: - -``` -[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → Request received -[f47ac10b-58cc-4372-a567-0e02b2c3d479] Contact created: cnt_abc123 -[f47ac10b-58cc-4372-a567-0e02b2c3d479] Event tracked: evt_xyz789 -[f47ac10b-58cc-4372-a567-0e02b2c3d479] POST /v1/track → 200 (127ms) -``` - -This makes it easy to trace a single request from start to finish. - -## Providing Request IDs with Load Balancers - -If you use a load balancer or API gateway, you can pass your own request IDs: - -```bash -curl -X POST https://api.useplunk.com/v1/send \ - -H "X-Request-ID: your-custom-request-id" \ - -H "Authorization: Bearer sk_..." \ - -H "Content-Type: application/json" \ - -d '...' -``` - -Plunk will use your provided request ID instead of generating a new one. This allows you to: -- Correlate requests across your entire system -- Trace requests from your frontend → your backend → Plunk → email delivery -- Maintain consistent request IDs in your monitoring tools - -## Best Practices - -### 1. Always Log Request IDs - -```javascript -// ✅ Good: Log request ID for correlation -const response = await plunk.send(email); -const requestId = response.headers.get('X-Request-ID'); -logger.info(`Email sent to ${email.to}`, { requestId }); -``` - -```javascript -// ❌ Bad: Discard request ID -await plunk.send(email); -// No way to correlate this with Plunk's logs -``` - -### 2. Include in Error Reporting - -```javascript -// ✅ Good: Include request ID in error reports -try { - await plunk.send(email); -} catch (error) { - Sentry.captureException(error, { - extra: { - requestId: error.requestId, - emailTo: email.to - } - }); -} -``` - -### 3. Store for Audit Trails - -```javascript -// ✅ Good: Store request ID in your database -await db.emailLog.create({ - to: email.to, - subject: email.subject, - plunkRequestId: requestId, - sentAt: new Date() -}); -``` - -### 4. Return to End Users (Optional) - -For customer-facing applications, you can show request IDs to users: - -``` -❌ Error sending email. Please try again. -``` - -``` -❌ Error sending email. Please contact support and provide this reference: a1b2c3d4-e5f6 -``` - -## Monitoring and Observability - -Request IDs are essential for: - -- **Distributed tracing** - Follow requests across services -- **Error correlation** - Link errors to specific API calls -- **Performance monitoring** - Identify slow requests -- **Debugging production** - Reproduce issues without PII -- **Rate limit tracking** - Monitor usage patterns per project - -## FAQ - -### Do request IDs expire? - -No, request IDs are logged indefinitely (subject to your log retention policy). - -### Can I reuse request IDs? - -No, each request should have a unique ID. If you send the same request ID twice, logs will be mixed. - -### Are request IDs sequential? - -No, they are random UUIDs. This prevents information leakage about request volume. - -### Can I search by request ID in the dashboard? - -This feature is planned but not yet available. For now, contact support with the request ID. - -## Related Documentation - -- [Error Codes](/api-reference/errors) - Understanding API errors -- [API Reference](/api-reference/overview) - Complete API documentation -- [Troubleshooting](/guides/troubleshooting) - Common issues and solutions diff --git a/apps/wiki/content/docs/guides/scaling-email.mdx b/apps/wiki/content/docs/guides/scaling-email.mdx deleted file mode 100644 index f93c452..0000000 --- a/apps/wiki/content/docs/guides/scaling-email.mdx +++ /dev/null @@ -1,277 +0,0 @@ ---- -title: Scaling Email Delivery -description: Best practices for high-volume email sending ---- - -## Email delivery at scale - -Plunk is built on AWS SES and handles millions of emails. Follow these practices to maintain high deliverability and performance at scale. - -## Deliverability best practices - -### Use custom domains - -Emails from custom domains have higher trust and better deliverability than shared domains. - -**Setup:** -1. Go to **Settings > Domains** -2. Add your domain -3. Configure DNS records (DKIM, SPF) -4. Wait for verification - -[Learn more about custom domains →](/guides/custom-domains) - -### Warm up new domains - -Start small and gradually increase volume. - -This builds sender reputation with email providers. - -### Clean your list regularly - -Remove bounced and inactive contacts: - -```javascript -// Get bounced contacts -const bounced = await fetch('{{API_URL}}/events?name=email.bounced&limit=1000', { - headers: { 'Authorization': `Bearer ${apiKey}` } -}); - -// Unsubscribe them -for (const event of bounced.data.events) { - await fetch(`{{API_URL}}/contacts/${event.contactId}`, { - method: 'PATCH', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ subscribed: false }) - }); -} -``` - -**When to clean:** -- Hard bounces: Immediately unsubscribe -- Soft bounces: After 3 attempts -- No engagement: After 6-12 months - -### Segment your audience - -Send relevant content to engaged users: - -```javascript -// Create engaged users segment -{ - "name": "Engaged Users", - "filters": { - "operator": "OR", - "conditions": [ - { "field": "data.lastOpenedAt", "operator": "greaterThan", "value": "{{90 days ago}}" }, - { "field": "data.lastClickedAt", "operator": "greaterThan", "value": "{{90 days ago}}" } - ] - } -} -``` - -Send campaigns to engaged segments for better rates. - -## Tracking control - -You can disable tracking in your project settings for privacy-focused audiences or to reduce email size. - -**When tracking is disabled:** -- No tracking pixel (no open tracking) -- Links not rewritten (no click tracking) -- Smaller email size -- May improve deliverability for privacy-conscious audiences - -**When to disable:** -- Regulated industries (healthcare, finance) -- Privacy-focused users -- Transactional emails where tracking isn't needed -- High-volume sends where analytics aren't critical - -## Rate limits - -### AWS SES limits - -Plunk automatically queues emails to stay within limits. Large sends process in background. - -### Increase limits - -For higher throughput: -1. Maintain good sender reputation -2. Consistent sending volume -3. Low bounce/complaint rates -4. Request limit increase from AWS - -### Batch operations - -For bulk operations, use appropriate endpoints: - -```javascript -// ✓ Good: Single request for multiple recipients -fetch('{{API_URL}}/v1/send', { - method: 'POST', - body: JSON.stringify({ - to: ['user1@example.com', 'user2@example.com', 'user3@example.com'], - subject: 'Update', - body: 'Message' - }) -}); - -// ✗ Avoid: Multiple requests -for (const email of emails) { - await fetch('{{API_URL}}/v1/send', {...}); // Sequential, slow -} -``` - -## Campaign targeting - -### Dynamic filtering - -Target audiences without creating segments: - -```javascript -{ - "name": "Premium Launch", - "audienceType": "FILTERED", - "audienceFilter": { - "operator": "AND", - "conditions": [ - { "field": "data.plan", "operator": "equals", "value": "premium" }, - { "field": "data.signupDate", "operator": "greaterThan", "value": "2025-01-01" }, - { "field": "subscribed", "operator": "equals", "value": true } - ] - }, - "templateId": "template_id" -} -``` - -**Use FILTERED for:** -- One-time sends -- Testing targeting -- Very specific criteria - -**Use SEGMENT for:** -- Repeated targeting -- Workflow triggers -- Segment analytics - -## Segment membership tracking - -For segments used in workflows, enable membership tracking: - -```bash -curl -X POST {{API_URL}}/segments \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Active Premium Users", - "filters": {...}, - "trackMembership": true - }' -``` - -**Enable for:** -- Workflow trigger segments -- Lifecycle stage tracking -- Cohort analysis - -**Disable for:** -- Large segments (100k+ contacts) -- Campaign-only segments -- Frequently changing segments - -Membership updates run every 5 minutes in background. - -## Performance optimization - -### Cache contact data - -For high-volume API sends, cache contact lookups: - -```javascript -// Cache contact IDs locally -const contactCache = new Map(); - -async function getContactId(email) { - if (contactCache.has(email)) { - return contactCache.get(email); - } - - const contact = await fetch(`{{API_URL}}/contacts?search=${email}`); - contactCache.set(email, contact.id); - return contact.id; -} -``` - -### Use webhooks for async processing - -Instead of waiting for email sends: - -```javascript -// Workflow with webhook for confirmation -{ - "steps": [ - { "type": "SEND_EMAIL", "config": {...} }, - { - "type": "WEBHOOK", - "config": { - "url": "https://your-api.com/email-sent", - "method": "POST", - "body": { - "contactId": "{{id}}", - "emailId": "{{emailId}}" - } - } - } - ] -} -``` - -### Batch workflow triggers - -Trigger workflows in batches instead of one at a time: - -```javascript -// Batch event tracking -const events = users.map(user => ({ - email: user.email, - event: 'welcome_campaign', - data: { userId: user.id } -})); - -// Send in parallel (respecting rate limits) -await Promise.all( - events.map(event => - fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(event) - }) - ) -); -``` - -## Monitoring - -### Track key metrics - -Monitor these regularly: - -- **Bounce rate** — Should be < 2% -- **Complaint rate** — Should be < 0.1% -- **Open rate** — Industry average 15-25% -- **Click rate** — Industry average 2-5% - -Monitor these metrics in your dashboard analytics page to track deliverability and engagement over time. - -## Next Steps - -- [Set billing limits](/guides/billing-limits) to control costs -- [Monitor analytics](/guides/analytics) for deliverability -- [Troubleshoot issues](/guides/troubleshooting) if problems arise diff --git a/apps/wiki/content/docs/guides/segments.mdx b/apps/wiki/content/docs/guides/segments.mdx deleted file mode 100644 index 8b4f37e..0000000 --- a/apps/wiki/content/docs/guides/segments.mdx +++ /dev/null @@ -1,342 +0,0 @@ ---- -title: Segments -description: Create dynamic audience groups with filters ---- - -## What are segments - -Segments are dynamic groups of contacts based on filter conditions. Unlike static lists, segments automatically update as contact data changes. - -Use segments to: -- Target specific audiences in campaigns -- Trigger workflows when contacts enter/exit -- Analyze cohorts and user behavior -- Personalize communications - -## Creating segments - -### In the dashboard - -1. Go to **Segments** -2. Click **Create Segment** -3. Name your segment -4. Add filter conditions -5. Enable membership tracking (optional) -6. Save - -### Via API - -```bash -curl -X POST {{API_URL}}/segments \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Active Premium Users", - "filters": { - "operator": "AND", - "conditions": [ - { - "field": "data.plan", - "operator": "equals", - "value": "premium" - }, - { - "field": "subscribed", - "operator": "equals", - "value": true - } - ] - } - }' -``` - -## Filter conditions - -Combine conditions with `AND` or `OR` operators to build precise segments. - -### Available operators - -**Equality** -- `equals` — Exact match -- `notEquals` — Does not match - -**Text** -- `contains` — String includes value -- `notContains` — String excludes value - -**Numeric/Date** -- `greaterThan` — Larger than value -- `lessThan` — Smaller than value -- `greaterThanOrEquals` — At least value -- `lessThanOrEquals` — At most value - -**Arrays** -- `in` — Value in array -- `notIn` — Value not in array - -**Existence** -- `exists` — Field has a value -- `notExists` — Field is missing or null - -### Field paths - -Access contact fields with dot notation: - -- `email` — Contact email address -- `subscribed` — Subscription status -- `createdAt` — When contact was created -- `data.firstName` — Custom field -- `data.preferences.newsletter` — Nested field -- `data.lastPurchaseDate` — Custom date field - -## Example segments - -### Premium subscribers - -All contacts on premium plan who are subscribed: - -```json -{ - "operator": "AND", - "conditions": [ - { "field": "data.plan", "operator": "equals", "value": "premium" }, - { "field": "subscribed", "operator": "equals", "value": true } - ] -} -``` - -### Recent signups - -Contacts who joined in the last 7 days: - -```json -{ - "operator": "AND", - "conditions": [ - { - "field": "createdAt", - "operator": "greaterThan", - "value": "{{now - 7 days}}" - } - ] -} -``` - -### Inactive users - -Users who haven't logged in for 30+ days: - -```json -{ - "operator": "AND", - "conditions": [ - { - "field": "data.lastLoginAt", - "operator": "lessThan", - "value": "{{now - 30 days}}" - }, - { - "field": "data.lastLoginAt", - "operator": "exists", - "value": true - } - ] -} -``` - -### High-value customers - -Total spending over $1000: - -```json -{ - "operator": "AND", - "conditions": [ - { - "field": "data.totalSpent", - "operator": "greaterThanOrEquals", - "value": 1000 - }, - { - "field": "subscribed", - "operator": "equals", - "value": true - } - ] -} -``` - -### Free tier churned users - -Users who downgraded from paid to free: - -```json -{ - "operator": "AND", - "conditions": [ - { "field": "data.plan", "operator": "equals", "value": "free" }, - { "field": "data.previousPlan", "operator": "in", "value": ["pro", "premium"] }, - { "field": "data.downgradedAt", "operator": "exists", "value": true } - ] -} -``` - -## Membership tracking - -When you enable `trackMembership`: - -**What happens:** -- Plunk computes and stores segment membership -- When contacts enter, sends `segment.entered` event -- When contacts exit, sends `segment.exited` event -- Provides historical membership data - -**Use when:** -- You want to trigger workflows on entry/exit -- You need to track cohort changes over time -- Segment is relatively stable (not millions of changes per day) - -**Disable when:** -- Segment changes very frequently -- You only need current membership (not history) -- You have millions of contacts and want to save storage - -### Using entry/exit events - -With membership tracking enabled, use segment events to trigger workflows: - -```json -{ - "triggerType": "SEGMENT_ENTRY", - "triggerConfig": { - "segmentId": "high-value-customers" - } -} -``` - -Or use the generic event trigger: - -```json -{ - "triggerType": "EVENT", - "triggerConfig": { - "eventName": "segment.entered" - } -} -``` - -Event data includes: -```json -{ - "segmentId": "seg_abc123", - "segmentName": "High Value Customers" -} -``` - -## Using segments - -### In campaigns - -Send a campaign to a segment: - -```bash -curl -X POST {{API_URL}}/campaigns \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Premium Feature Announcement", - "audienceType": "SEGMENT", - "segmentId": "premium-users", - "templateId": "feature-announcement" - }' -``` - -### In workflows - -Trigger workflows when contacts enter a segment: - -```bash -curl -X POST {{API_URL}}/workflows \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "VIP Welcome", - "triggerType": "SEGMENT_ENTRY", - "triggerConfig": { - "segmentId": "high-value-customers" - } - }' -``` - -## Managing segments - -### Get segment with count - -```bash -curl -X GET {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns member count: - -```json -{ - "id": "segment_id", - "name": "Premium Users", - "memberCount": 1542, - "filters": {...} -} -``` - -### List segment members - -```bash -curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=50" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Update segment - -```bash -curl -X PATCH {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Updated name", - "filters": { - "operator": "AND", - "conditions": [...] - } - }' -``` - -When you update filters, membership is recomputed automatically. - -### Delete segment - -```bash -curl -X DELETE {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Active campaigns and workflows using this segment will stop working. - -## Best practices - -**Start broad, then narrow** — Create general segments first, then add specific conditions. - -**Test your filters** — Preview segment size before using in campaigns to avoid sending to wrong audience. - -**Name clearly** — Use descriptive names: "Q4 2024 Premium Signups" not "Segment 3". - -**Avoid overlapping segments** — If using in workflows, ensure segments don't overlap to prevent duplicate emails. - -**Use for analysis** — Create segments to understand user cohorts, even if not used in campaigns. - -**Combine with events** — Track events and use them in segment conditions for behavior-based targeting. - -## What's next - -- [Build campaigns](/guides/campaigns) to send to segments -- [Create workflows](/guides/workflows) triggered by segment entry -- [Track events](/guides/events) to use in segment filters diff --git a/apps/wiki/content/docs/guides/templates.mdx b/apps/wiki/content/docs/guides/templates.mdx deleted file mode 100644 index 39aa5e5..0000000 --- a/apps/wiki/content/docs/guides/templates.mdx +++ /dev/null @@ -1,287 +0,0 @@ ---- -title: Templates -description: Create reusable email templates ---- - -## Why use templates - -Templates let you design emails once and reuse them across: -- Transactional API sends (`/v1/send`) -- Automated workflows - -Benefits: -- Update design in one place, applies everywhere -- Maintain consistent branding -- Separate content from code - -## Template types - -### Marketing templates - -**Use for:** Newsletters, promotions, announcements - -- Only sends to subscribed contacts -- Automatically includes unsubscribe link -- Respects subscription preferences - -### Transactional templates - -**Use for:** Order confirmations, password resets, receipts - -- Sends to all contacts, even if unsubscribed -- No unsubscribe link required -- Critical business communications - -Choose the right type based on content, not delivery method. You can use both types in campaigns, workflows, and API calls. The type determines subscription enforcement. - -## Creating templates - -### In the dashboard - -1. Go to **Templates** -2. Click **Create Template** -3. Choose type (Marketing or Transactional) -4. Set subject, from address, and body -5. Add variables using `{{variableName}}` -6. Save - -### Via API - -```bash -curl -X POST {{API_URL}}/templates \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "order-confirmation", - "subject": "Order #{{orderNumber}} confirmed", - "body": "

Thanks for your order!

Order #{{orderNumber}} will arrive by {{deliveryDate}}.

", - "from": "orders@example.com", - "fromName": "Acme Store", - "type": "TRANSACTIONAL" - }' -``` - -## Using variables - -Variables let you personalize each email. Use `{{variableName}}` syntax: - -```html -

Hello {{firstName}}!

-

Your {{plan}} subscription renews on {{renewalDate}}.

-

Total: ${{amount}}

-``` - -When sending, provide values in the `data` object: - -```json -{ - "template": "subscription-renewal", - "data": { - "firstName": "Sarah", - "plan": "Pro", - "renewalDate": "April 15, 2024", - "amount": "29.00" - } -} -``` - -## Persistent vs. Temporary Data - -Template variables can come from **persistent contact data** or **temporary non-persistent data**: - -**Persistent data** — Saved to contact profile: -```json -{ - "to": "user@example.com", - "subject": "Welcome {{firstName}}!", - "body": "

Your {{plan}} subscription is active.

", - "data": { - "firstName": "John", // Saved to contact - "plan": "Pro" // Saved to contact - } -} -``` - -**Non-persistent data** — Used only for this email: -```json -{ - "to": "user@example.com", - "subject": "Password Reset", - "body": "

Reset code: {{resetCode}}

Hello {{firstName}}!

", - "data": { - "firstName": "John", // Saved to contact - "resetCode": {value: "ABC123", persistent: false} // NOT saved to contact - } -} -``` - -**When to use non-persistent data:** -- One-time verification codes or tokens -- Temporary URLs (password reset, magic links) -- Session-specific information -- Data that shouldn't pollute contact profiles - -**In workflows:** -Non-persistent data from events is available throughout the entire workflow execution via the execution context, allowing you to use tokens/URLs across multiple workflow steps. - -### Fallback values - -Provide defaults for missing data: - -```html -

Hello {{firstName ?? 'there'}}!

-``` - -If `firstName` isn't provided, displays "Hello there!" instead. - -### Nested data - -Access nested objects with dot notation: - -```html -

{{user.email}}

-

{{order.items.0.name}}

-

{{preferences.newsletter}}

-``` - -## Sending with templates - -### Transactional emails - -Use the `template` field with the **template ID** (not the template name): - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "template": "clx123abc456", - "data": { - "orderNumber": "12345", - "deliveryDate": "March 20" - } - }' -``` - -**Finding your template ID:** -- In the dashboard: Go to Templates → Click on your template → Copy the ID from the URL or template details -- Via API: Use `GET /templates` to list all templates with their IDs - -When using a template: -- **Subject, body, from, and reply-to** are automatically taken from the template -- **Template variables** (e.g., `{{orderNumber}}`) are populated from the `data` field -- You can **override** any template value by explicitly providing it in the request (see example below) - -### Overriding template values - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "template": "clx123abc456", - "subject": "Custom Subject (overrides template)", - "from": { - "name": "Custom Sender", - "email": "custom@example.com" - }, - "data": { - "orderNumber": "12345" - } - }' -``` - -### In workflows - -When creating a **Send Email** workflow step, select the template from the dropdown. Variables are automatically filled from contact data and workflow context. - -### In campaigns - -When creating a campaign, choose a template instead of composing inline. All campaign recipients get the same template with personalized variables. - -## When to use inline vs templates - -**Use templates when:** -- Sending the same email repeatedly -- Multiple workflows/campaigns use same design -- Design may change over time -- Want to centralize branding - -**Use inline content when:** -- One-off transactional emails -- Unique per-user content -- Testing or prototyping -- Content is generated dynamically - -Example inline send: - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "user@example.com", - "subject": "Your verification code", - "body": "

Your code: {{code}}

", - "data": {"code": "ABC123"} - }' -``` - -## Managing templates - -### List templates - -```bash -curl -X GET {{API_URL}}/templates \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Filter by type: - -```bash -curl -X GET "{{API_URL}}/templates?type=MARKETING" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Update template - -```bash -curl -X PATCH {{API_URL}}/templates/template_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "subject": "New subject line", - "body": "

Updated content

" - }' -``` - -Changes apply to all future sends using this template. - -### Delete template - -```bash -curl -X DELETE {{API_URL}}/templates/template_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Templates used in active workflows are not deleted—you'll need to update workflows first. - -## Best practices - -**Keep templates simple** — Focus on content, avoid complex layouts that break in email clients. - -**Test across clients** — Email rendering varies. Preview in Gmail, Outlook, Apple Mail, and mobile devices. - -**Use semantic HTML** — Use `

`, `

`, `` instead of styled `

` elements. - -**Provide all variables** — Missing variables display as empty. Use fallbacks: `{{name ?? 'Customer'}}`. - -**Version your templates** — For critical transactional emails, create new templates rather than editing existing ones. - -## Next Steps - -- [Create campaigns](/guides/campaigns) with your templates -- [Build workflows](/guides/workflows) with automated emails -- [Track performance](/guides/analytics) of your templates diff --git a/apps/wiki/content/docs/guides/troubleshooting.mdx b/apps/wiki/content/docs/guides/troubleshooting.mdx deleted file mode 100644 index dc0ec14..0000000 --- a/apps/wiki/content/docs/guides/troubleshooting.mdx +++ /dev/null @@ -1,427 +0,0 @@ ---- -title: Troubleshooting -description: Common issues and solutions ---- - -## Authentication issues - -### Invalid API key error - -**Error:** -```json -{ - "code": 401, - "error": "Unauthorized", - "message": "Invalid API key" -} -``` - -**Solutions:** - -1. **Verify key format** — Secret keys start with `sk_`, public keys start with `pk_` -2. **Check Authorization header** — Must use `Bearer` format: `Authorization: Bearer sk_your_secret_key` -3. **Ensure key hasn't been regenerated** — If you regenerated keys in dashboard, update your application -4. **Verify project access** — Key must belong to the project you're accessing - -**Test your key:** -```bash -curl -X GET {{API_URL}}/contacts?limit=1 \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Wrong key type for endpoint - -**Error:** -```json -{ - "code": 401, - "error": "Unauthorized", - "message": "This endpoint requires a secret key (sk_*)" -} -``` - -**Solution:** - -You're using a public key (`pk_*`) for an endpoint that requires a secret key. - -- **Public keys** — Only work with `/v1/track` endpoint -- **Secret keys** — Required for all other endpoints - -Use your secret key from **Settings > API Keys** in the dashboard. - -## Email sending issues - -### Emails not sending - -**Check these common causes:** - -1. **Billing limit reached** - Check your billing limits in Settings → Billing. If you hit your monthly limit, increase it or wait for monthly reset. - -2. **Template not found** -```json -{ - "code": 404, - "error": "Not Found", - "message": "Template not found" -} -``` - -Verify template ID exists and belongs to your project. - -3. **Contact unsubscribed** - -Marketing templates skip unsubscribed contacts. Check contact subscription status: -```bash -curl -X GET {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -If `subscribed: false`, use a transactional template for critical emails. - -4. **Project disabled** - -If your project is disabled, all email sends will fail. Contact support. - -### Emails going to spam - -**Common causes:** - -1. **No custom domain** — Emails from default domain may have lower trust -2. **Low engagement** — Recipients not opening/clicking emails -3. **High bounce rate** — Too many invalid email addresses -4. **Spam complaints** — Recipients marking as spam - -**Solutions:** - -1. **Set up custom domain** — [Add your domain](/guides/custom-domains) for better deliverability -2. **Clean your list** — Remove bounced and inactive contacts -3. **Improve content** — Avoid spam trigger words, include clear unsubscribe link -4. **Warm up your domain** — Start with small volumes, gradually increase -5. **Segment your audience** — Only send relevant content to engaged users - -### Tracking not working - -**Open tracking:** - -Requires: -- `trackingEnabled: true` on project -- Recipient email client loads images -- HTML email (not plain text) - -Some email clients block tracking pixels. Open rates are estimates, not exact. - -**Click tracking:** - -Requires: -- Tracking enabled on project (check your project settings in dashboard) -- Links in email body (not subject) -- HTML email format - -## Campaign issues - -### Campaign won't send - -**Error:** -```json -{ - "code": 400, - "error": "Bad Request", - "message": "Campaign must be in DRAFT or SCHEDULED status" -} -``` - -**Solution:** - -Can only send campaigns with status `DRAFT`. If campaign is `SENT` or `CANCELLED`, duplicate it: - -```bash -curl -X POST {{API_URL}}/campaigns/campaign_id/duplicate \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### No recipients for campaign - -**Cause:** - -Campaign targets a segment or filter with zero matching contacts. - -**Solutions:** - -1. **Check segment size** -```bash -curl -X GET {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Look at `memberCount` field. - -2. **Verify filters** — Test filters on segments page to see matches - -3. **Check subscription status** — Marketing templates only send to subscribed contacts - -### Scheduled campaign didn't send - -**Check:** - -1. **Verify schedule time** — Must be in future when scheduled -2. **Campaign status** — Should be `SCHEDULED`, not `CANCELLED` -3. **Project limits** — Billing limits may block sends - -View campaign details: -```bash -curl -X GET {{API_URL}}/campaigns/campaign_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -## Workflow issues - -### Workflow not triggering - -**Common causes:** - -1. **Workflow not enabled** - -Check `enabled: true`: -```bash -curl -X GET {{API_URL}}/workflows/workflow_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -2. **Wrong event name** - -Event names are case-sensitive. `user_signed_up` ≠ `User_Signed_Up` - -Verify event name exactly matches workflow trigger. - -3. **Contact already entered (allowReentry: false)** - -If `allowReentry: false`, contact can only enter once. Check execution history: -```bash -curl -X GET {{API_URL}}/workflows/workflow_id/executions?contactId=contact_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -4. **Segment membership not tracked** - -For `SEGMENT_ENTRY` triggers, segment must have `trackMembership: true`. - -### Workflow emails not sending - -**Check each email step execution:** - -```bash -curl -X GET {{API_URL}}/workflows/workflow_id/executions/execution_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Look for step failures in response. - -**Common causes:** - -1. **Template not found** — Template was deleted -2. **Contact unsubscribed** — Marketing templates skip unsubscribed contacts -3. **Billing limit reached** — Monthly workflow email limit exceeded -4. **Workflow execution stopped** — Contact was deleted or execution was cancelled - -### Workflow stuck on delay step - -Delays are processed by background jobs. Check: - -1. **scheduledFor** time — When should it execute? -2. **Current time** — Has the scheduled time passed? - -Delays process every minute. Wait a few minutes and check again. - -## Contact issues - -### Contact not found - -**Error:** -```json -{ - "code": 404, - "error": "Not Found", - "message": "Contact not found" -} -``` - -**Solutions:** - -1. **Verify contact ID** — Check for typos in the ID -2. **Check project** — Contact may belong to different project -3. **Contact deleted** — Contact may have been deleted - -Search by email instead: -```bash -curl -X GET "{{API_URL}}/contacts?search=user@example.com" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Duplicate contacts - -Plunk automatically prevents duplicates. Creating a contact with existing email updates that contact instead of creating a new one. - -If you see duplicates: -1. Check email addresses carefully (they may differ slightly) -2. Verify you're viewing the same project - -### Contact data not updating - -**Verify data format:** - -```bash -curl -X PATCH {{API_URL}}/contacts/contact_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "data": { - "plan": "premium" - } - }' -``` - -**Common issues:** - -1. **Missing `data` wrapper** — Custom fields must be inside `data` object -2. **Wrong data types** — Numbers should be `99`, not `"99"` -3. **Nested too deeply** — Keep data relatively flat - -## Segment issues - -### Segment shows wrong count - -Segment counts update every 5 minutes. Wait a few minutes and refresh. - -For real-time count, query members directly: -```bash -curl -X GET "{{API_URL}}/segments/segment_id/contacts?limit=1" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Check `total` field in response. - -### Segment filter not working - -**Test your filter:** - -1. **Check field names** — Use `data.fieldName` for custom fields -2. **Verify operators** — `equals` for strings, `greaterThan` for numbers -3. **Match data types** — Don't compare string `"99"` with number `99` - -**Example filters:** - -```javascript -// ❌ Wrong -{ "field": "plan", "operator": "equals", "value": "pro" } - -// ✓ Correct -{ "field": "data.plan", "operator": "equals", "value": "pro" } -``` - -### Segment entry/exit events not firing - -Requires `trackMembership: true` on segment: - -```bash -curl -X PATCH {{API_URL}}/segments/segment_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{"trackMembership": true}' -``` - -Membership is computed every 5 minutes. Events fire on the next computation cycle after a contact's segment membership changes. - -## Domain verification issues - -### Domain won't verify - -**Common causes:** - -1. **DNS not propagated** — Can take up to 48 hours -2. **Wrong DNS records** — Double-check DKIM tokens -3. **Conflicting records** — Remove old DKIM records from other email services - -**Check DNS propagation:** -```bash -dig TXT _domainkey.yourdomain.com -``` - -Records should match the DKIM tokens provided by Plunk. - -**Force verification check:** -```bash -curl -X POST {{API_URL}}/domains/domain_id/verify \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Emails not sending from custom domain - -1. **Verify domain is verified** — Check `verified: true` in domain settings -2. **Use correct `from` address** — Must be `@yourdomain.com` -3. **Check SPF and DKIM** — Ensure DNS records are correct - -## Rate limiting - -### 429 Too Many Requests - -**Error:** -```json -{ - "code": 429, - "error": "Too Many Requests", - "message": "Rate limit exceeded" -} -``` - -**Solutions:** - -1. **Implement exponential backoff** — Wait and retry with increasing delays -2. **Batch operations** — Group multiple operations when possible -3. **Spread requests** — Distribute load over time instead of bursts - -**Example retry logic:** -```javascript -async function sendWithRetry(data, maxRetries = 3) { - for (let i = 0; i < maxRetries; i++) { - try { - const response = await fetch('{{API_URL}}/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${apiKey}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - if (response.status === 429) { - const waitTime = Math.pow(2, i) * 1000; // Exponential backoff - await new Promise(resolve => setTimeout(resolve, waitTime)); - continue; - } - - return response; - } catch (error) { - if (i === maxRetries - 1) throw error; - } - } -} -``` - -## Getting help - -If you're still experiencing issues: - -1. **Check API status** — Is there a known outage? -2. **Review error message** — Error messages usually indicate the problem -3. **Search documentation** — Look for specific error codes or messages -4. **Check GitHub issues** — Similar issues may already be reported -5. **Join Discord community** — Ask for help from other users -6. **Contact support** — Provide error details, request IDs, and steps to reproduce - -**Include in support requests:** -- Error message and status code -- API endpoint and method -- Request body (remove sensitive data) -- Timestamp of the issue -- Project ID (if applicable) - diff --git a/apps/wiki/content/docs/guides/webhooks.mdx b/apps/wiki/content/docs/guides/webhooks.mdx deleted file mode 100644 index 9d62b35..0000000 --- a/apps/wiki/content/docs/guides/webhooks.mdx +++ /dev/null @@ -1,262 +0,0 @@ ---- -title: Webhooks -description: Send real-time notifications to external services ---- - -## What are webhooks - -Webhooks let you send HTTP requests to external services from within workflows. Use them to: - -- Notify your CRM when workflows complete -- Update external databases with contact actions -- Trigger third-party automations -- Sync data across systems -- Track workflow progress in analytics tools - -## Using webhooks in workflows - -Add a **Webhook** step to any workflow: - -1. Go to **Workflows** -2. Create or edit a workflow -3. Add a **Webhook** step -4. Configure the HTTP request -5. Connect to other steps - -### Basic webhook configuration - -```json -{ - "type": "WEBHOOK", - "config": { - "url": "https://your-api.com/webhook", - "method": "POST", - "headers": { - "Content-Type": "application/json", - "Authorization": "Bearer your_api_token" - }, - "body": { - "email": "{{email}}", - "event": "workflow_completed", - "contactId": "{{id}}" - } - } -} -``` - -### Supported HTTP methods - -- **POST** — Most common, sends data to endpoint -- **PUT** — Update existing resource -- **PATCH** — Partial update -- **GET** — Retrieve data (rarely used in workflows) -- **DELETE** — Remove resource - -## Using contact variables - -Access contact data in your webhook using template variables: - -```json -{ - "url": "https://crm.example.com/contacts", - "method": "POST", - "body": { - "email": "{{email}}", - "firstName": "{{data.firstName}}", - "lastName": "{{data.lastName}}", - "plan": "{{data.plan}}", - "workflowName": "{{workflowName}}", - "completedAt": "{{now}}" - } -} -``` - -Available variables: -- `{{email}}` — Contact email -- `{{id}}` — Contact ID -- `{{data.fieldName}}` — Any custom data field -- `{{workflowName}}` — Current workflow name -- `{{now}}` — Current timestamp - -## Common use cases - -### Notify Slack - -```json -{ - "url": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", - "method": "POST", - "body": { - "text": "New user completed onboarding: {{email}}" - } -} -``` - -### Update CRM - -```json -{ - "url": "https://api.crm.com/contacts/{{data.crmId}}", - "method": "PATCH", - "headers": { - "Authorization": "Bearer crm_api_token" - }, - "body": { - "onboardingCompleted": true, - "lastEngaged": "{{now}}" - } -} -``` - -### Track analytics - -```json -{ - "url": "https://analytics.example.com/events", - "method": "POST", - "headers": { - "X-API-Key": "analytics_key" - }, - "body": { - "event": "workflow_milestone", - "userId": "{{email}}", - "properties": { - "workflow": "{{workflowName}}", - "step": "purchase_completed" - } - } -} -``` - -### Trigger Zapier - -```json -{ - "url": "https://hooks.zapier.com/hooks/catch/YOUR_WEBHOOK_ID/", - "method": "POST", - "body": { - "email": "{{email}}", - "firstName": "{{data.firstName}}", - "event": "trial_ended" - } -} -``` - -## Error handling - -### Webhook failures - -If a webhook request fails: -- Workflow continues to next step -- Error is logged in workflow execution -- Contact is not blocked - -Check workflow execution logs to see webhook errors: - -```bash -curl -X GET {{API_URL}}/workflows/workflow_id/executions \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Timeouts - -Webhooks timeout after 30 seconds. If your endpoint takes longer: -- Use async processing on your end -- Return 202 Accepted immediately -- Process in background job - -### Retry logic - -Webhooks are **not automatically retried**. If you need guaranteed delivery: -- Implement retry logic in your endpoint -- Use a message queue (SQS, RabbitMQ) -- Track webhook status in your database - -## Receiving email event webhooks - -Plunk tracks email events automatically (opens, clicks, bounces). Access them via: - -### Events API - -```bash -curl -X GET "{{API_URL}}/events?contactId=contact_id" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Returns email events: -```json -{ - "events": [ - { - "name": "email.opened", - "data": { "emailId": "...", "timestamp": "..." } - }, - { - "name": "email.clicked", - "data": { "emailId": "...", "url": "...", "timestamp": "..." } - } - ] -} -``` - -### Trigger workflows on email events - -Create a workflow triggered by email events: - -```json -{ - "triggerType": "EVENT", - "triggerConfig": { - "eventName": "email.clicked" - } -} -``` - -Then use a webhook step to forward to your system. - -## Security best practices - -**Use HTTPS only** — Never send sensitive data over HTTP. - -**Authenticate requests** — Include API tokens in headers: -```json -{ - "headers": { - "Authorization": "Bearer your_secret_token" - } -} -``` - -**Validate on receiving end** — Don't trust webhook data blindly. Verify it matches your expectations. - -**Don't expose secrets** — Store API tokens as environment variables, not in workflow config. - -**Rate limit your endpoint** — Protect against webhook floods. - -## Testing webhooks - -### Use webhook.site - -For testing, use [webhook.site](https://webhook.site): - -1. Go to webhook.site -2. Copy your unique URL -3. Use it in your workflow webhook step -4. Trigger the workflow -5. See the request in webhook.site - -### Test with ngrok - -For local development: - -```bash -ngrok http 3000 -``` - -Use the ngrok URL in your webhook configuration. Requests will tunnel to your local server. - -## What's next - -- [Build workflows](/guides/workflows) with webhook steps -- [Track events](/guides/events) to trigger webhooks -- [Monitor analytics](/guides/analytics) for webhook success rates diff --git a/apps/wiki/content/docs/guides/workflow-automation.mdx b/apps/wiki/content/docs/guides/workflow-automation.mdx deleted file mode 100644 index 1fa8911..0000000 --- a/apps/wiki/content/docs/guides/workflow-automation.mdx +++ /dev/null @@ -1,211 +0,0 @@ ---- -title: Advanced Workflow Automation -description: Control workflow re-entry and pass execution context data ---- - -## Workflow Re-Entry Control - -Control whether contacts can enter the same workflow multiple times. - -### Allow Re-Entry - -Use when the workflow represents a repeating process: - -```javascript -{ - "name": "Weekly Newsletter", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "newsletter.send" }, - "allowReentry": true // Contacts can re-enter -} -``` - -**When to use**: -- Recurring campaigns (weekly newsletters) -- Event-based sequences (cart abandoned) -- Behavior triggers that can happen multiple times - -**What happens**: Contact can have multiple active executions of the same workflow. - -### Prevent Re-Entry (Default) - -Use for one-time journeys: - -```javascript -{ - "name": "Onboarding Series", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "user.signup" }, - "allowReentry": false // One-time only (default) -} -``` - -**When to use**: -- User onboarding -- Trial expiration -- Welcome sequences - -**What happens**: Contact can only enter once, even if the trigger fires again. - -## Execution Context - -Pass event-specific data when starting a workflow execution. - -### Basic Example - -```javascript -// Start workflow with order-specific context -fetch('/workflows/workflow_id/executions', { - method: 'POST', - body: JSON.stringify({ - contactId: 'contact_id', - context: { - orderNumber: 'ORD-12345', - orderTotal: 299.99, - deliveryDate: '2024-03-30' - } - }) -}); -``` - -In your workflow email templates: - -```html -

Hi {'{{firstName}}'}!

-

Order #{'{{orderNumber}}'}: ${'{{orderTotal}}'}

-

Delivery: {'{{deliveryDate}}'}

-``` - -The template accesses both contact data (`firstName`) and context data (`orderNumber`, `orderTotal`, `deliveryDate`). - -### When to Use Context - -**Use execution context for**: -- Order-specific details -- Event registration info -- Session data -- Campaign-specific values - -**Update contact data for**: -- Persistent user attributes -- Cumulative metrics (total orders, lifetime value) -- Segment-able fields - -### Example: Order Confirmation Workflow - -```javascript -// Trigger workflow on purchase -{ - "name": "Order Confirmation", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "purchase.completed" }, - "allowReentry": true // Can purchase multiple times -} - -// Start execution with order context -fetch('/workflows/order_workflow_id/executions', { - method: 'POST', - body: JSON.stringify({ - contactId: 'contact_id', - context: { - orderNumber: 'ORD-456', - total: 199.99, - trackingUrl: 'https://track.example.com/456' - } - }) -}); -``` - -Workflow sends emails with order-specific details while tracking cumulative purchase data on the contact. - -## Real-World Patterns - -### Pattern 1: Trial Workflow - -```javascript -{ - "name": "14-Day Trial", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "trial.started" }, - "allowReentry": false // Only trial once -} - -// Workflow steps: -// Day 0: Welcome email -// Day 7: Mid-trial check-in -// Day 13: Upgrade reminder -``` - -### Pattern 2: Cart Abandonment - -```javascript -{ - "name": "Cart Abandoned", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "cart.abandoned" }, - "allowReentry": true // Can abandon multiple times -} - -// Pass cart data as context -context: { - cartTotal: 99.99, - cartUrl: 'https://app.example.com/cart/abc' -} -``` - -### Pattern 3: Event Reminders - -```javascript -{ - "name": "Webinar Reminders", - "triggerType": "EVENT", - "triggerConfig": { "eventName": "webinar.registered" }, - "allowReentry": true // Can register for multiple webinars -} - -// Pass webinar details as context -context: { - webinarTitle: 'Email Automation Masterclass', - webinarDate: '2024-04-10', - webinarLink: 'https://zoom.us/j/12345' -} -``` - -## Monitoring Executions - -Check workflow execution status: - -```bash -curl -X GET "/workflows/workflow_id/executions/execution_id" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -Response shows step progress: - -```json -{ - "success": true, - "data": { - "id": "execution_id", - "status": "active", - "steps": [ - { - "stepId": "step_1", - "status": "completed", - "completedAt": "2024-03-15T10:00:00Z" - }, - { - "stepId": "step_2", - "status": "waiting", - "waitingUntil": "2024-03-16T10:00:00Z" - } - ] - } -} -``` - -## Next Steps - -- [Create workflows](/guides/workflows) to automate sequences -- [Track events](/guides/events) to trigger workflows -- [Set up segments](/guides/segments) for segment-based triggers diff --git a/apps/wiki/content/docs/guides/workflows.mdx b/apps/wiki/content/docs/guides/workflows.mdx deleted file mode 100644 index 26cf940..0000000 --- a/apps/wiki/content/docs/guides/workflows.mdx +++ /dev/null @@ -1,445 +0,0 @@ ---- -title: Workflows -description: Automate email sequences with triggers and conditions ---- - -## What are workflows - -Workflows automate email sequences based on user behavior. Build onboarding drips, re-engagement campaigns, and event-triggered emails—all without writing code. - -A workflow consists of: -- **Trigger** — What starts the workflow (event, segment entry, schedule) -- **Steps** — Actions like sending emails, waiting, or checking conditions -- **Transitions** — Connections between steps that define the flow - -## Common use cases - -### Welcome series - -Send a 3-email onboarding sequence when users sign up: - -1. User triggers `signed_up` event -2. Send welcome email immediately -3. Wait 1 day -4. Send getting started guide -5. Wait 2 days -6. Send feature tips - -### Abandoned cart recovery - -Re-engage users who add items but don't purchase: - -1. User triggers `cart_abandoned` event -2. Wait 1 hour -3. Send reminder email with cart contents -4. Wait for `purchase` event (timeout: 24 hours) -5. If purchased → Exit workflow -6. If timeout → Send discount offer - -### Trial expiration - -Notify users before trial ends and encourage upgrade: - -1. Trigger daily at 9am -2. Check if trial expires in 3 days -3. If yes → Send upgrade reminder -4. Wait for `subscription_created` event (timeout: 3 days) -5. If subscribed → Send thank you email -6. If timeout → Send last chance offer - -### Re-engagement campaign - -Win back inactive users: - -1. Contact enters "Inactive Users" segment -2. Send "We miss you" email -3. Wait for `login` event (timeout: 7 days) -4. If logged in → Exit workflow -5. If timeout → Send special offer - -## Creating workflows - -### In the dashboard - -1. Go to **Workflows** -2. Click **Create Workflow** -3. Name your workflow -4. Choose trigger type -5. Add steps using visual builder -6. Connect steps with transitions -7. Activate workflow - -### Via API - -```bash -curl -X POST {{API_URL}}/workflows \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "name": "Welcome Series", - "eventName": "signed_up", - "enabled": true - }' -``` - -Then add steps and transitions through the dashboard or API. - -## Trigger types - -### Event trigger - -Starts when a specific event is tracked: - -```json -{ - "triggerType": "EVENT", - "triggerConfig": { - "eventName": "signed_up" - } -} -``` - -Track the event with `/v1/track` to start the workflow. - -### Segment entry - -Starts when contact joins a segment: - -```json -{ - "triggerType": "SEGMENT_ENTRY", - "triggerConfig": { - "segmentId": "premium-users" - } -} -``` - -Requires segment to have `trackMembership: true`. - -### Segment exit - -Starts when contact leaves a segment: - -```json -{ - "triggerType": "SEGMENT_EXIT", - "triggerConfig": { - "segmentId": "trial-users" - } -} -``` - -### Schedule - -Runs on a cron schedule: - -```json -{ - "triggerType": "SCHEDULE", - "triggerConfig": { - "schedule": "0 9 * * *" - } -} -``` - -Evaluates all contacts—use conditions to filter who continues. - -## Workflow steps - -### Send Email - -Send a template to the contact: - -```json -{ - "type": "SEND_EMAIL", - "config": { - "templateId": "welcome-template" - } -} -``` - -### Delay - -Wait before continuing: - -```json -{ - "type": "DELAY", - "config": { - "duration": 86400, - "unit": "seconds" - } -} -``` - -Common durations: -- 1 hour: `3600` -- 1 day: `86400` -- 1 week: `604800` - -### Wait for Event - -Pause until an event occurs or timeout: - -```json -{ - "type": "WAIT_FOR_EVENT", - "config": { - "eventName": "purchase", - "timeout": 604800 - } -} -``` - -Create two transitions: one for "success" (event occurred) and one for "timeout". - -### Condition - -Branch based on contact data: - -```json -{ - "type": "CONDITION", - "config": { - "field": "data.plan", - "operator": "equals", - "value": "premium" - } -} -``` - -Create two transitions: "true" and "false". - -### Update Contact - -Modify contact fields: - -```json -{ - "type": "UPDATE_CONTACT", - "config": { - "data": { - "onboardingCompleted": true, - "completedAt": "{{now}}" - } - } -} -``` - -### Webhook - -Call an external API: - -```json -{ - "type": "WEBHOOK", - "config": { - "url": "https://api.example.com/webhook", - "method": "POST", - "body": { - "email": "{{email}}", - "event": "workflow_completed" - } - } -} -``` - -### Exit - -End the workflow: - -```json -{ - "type": "EXIT" -} -``` - -## Re-entry behavior - -Control whether contacts can enter a workflow multiple times: - -### Prevent re-entry (default) - -**allowReentry: false** -- Contact can only enter once, ever -- Subsequent triggers are ignored -- Use for one-time journeys - -**Best for:** -- User onboarding sequences -- Welcome series -- Trial expiration flows -- One-time educational content - -**Example:** -```json -{ - "name": "Onboarding Series", - "eventName": "user_signed_up", - "allowReentry": false, - "enabled": true -} -``` - -Even if the `user_signed_up` event fires multiple times for the same contact, they'll only enter once. - -### Allow re-entry - -**allowReentry: true** -- Contact can enter multiple times -- Each trigger starts a new execution -- Multiple executions can run simultaneously - -**Best for:** -- Recurring events (weekly newsletters, monthly reports) -- Behavior-triggered campaigns (cart abandonment, content engagement) -- Event-specific sequences (order confirmations, webinar reminders) - -**Example:** -```json -{ - "name": "Cart Abandoned Reminder", - "eventName": "cart_abandoned", - "allowReentry": true, - "enabled": true -} -``` - -User abandons cart multiple times → Each triggers a new workflow execution. - -## Execution context - -Pass event-specific data when workflows are triggered automatically. - -### What is context? - -Context is temporary data passed with a workflow execution that's available in templates but not saved to the contact record. - -**Contact data vs Context:** -- **Contact data** — Persistent, saved to contact, used for segmentation -- **Execution context** — Temporary, specific to this workflow run, not saved - -### Using context in templates - -Context data is available in workflow email templates alongside contact data: - -```html -

Hi {{firstName}}!

-

Order #{{orderNumber}} confirmed!

-

Total: ${{orderTotal}}

-

Tracking: View shipment

-``` - -Where: -- `{{firstName}}` comes from contact.data -- `{{orderNumber}}`, `{{orderTotal}}`, `{{trackingUrl}}` come from execution context - -### Common patterns - -**Order confirmations:** -```javascript -// Event includes order details -{ - event: 'purchase_completed', - email: 'user@example.com', - data: { - // Saved to contact - totalPurchases: 5, - lifetimeValue: 599 - } -} - -// Workflow execution receives context (not saved) -context: { - orderNumber: 'ORD-12345', - orderTotal: 99.99, - trackingUrl: 'https://track.example.com/12345', - deliveryDate: '2025-12-05' -} -``` - -**Event registrations:** -```javascript -context: { - eventTitle: 'Email Marketing Workshop', - eventDate: '2025-12-10T14:00:00Z', - eventUrl: 'https://zoom.us/j/123456', - speakerName: 'Jane Doe' -} -``` - -**Cart abandonment:** -```javascript -context: { - cartTotal: 149.99, - cartUrl: 'https://app.example.com/cart/abc123', - itemCount: 3, - expiresAt: '2025-12-01T10:00:00Z' -} -``` - -## Workflow execution - -When a workflow triggers: - -1. Contact enters at the trigger step -2. Executes each step in sequence -3. Follows transitions between steps -4. Continues until reaching Exit step -5. Status changes from RUNNING to COMPLETED - -If contact unsubscribes or is deleted, workflow execution stops immediately. - -## Managing workflows - -### List workflows - -```bash -curl -X GET {{API_URL}}/workflows \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Get workflow details - -```bash -curl -X GET {{API_URL}}/workflows/workflow_id \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -### Activate/deactivate - -```bash -curl -X PATCH {{API_URL}}/workflows/workflow_id \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{"enabled": true}' -``` - -### View executions - -```bash -curl -X GET "{{API_URL}}/workflows/workflow_id/executions" \ - -H "Authorization: Bearer sk_your_secret_key" -``` - -## Best practices - -**Start simple** — Begin with 2-3 emails before adding complex conditions. - -**Test with yourself** — Create a test contact and trigger the workflow to verify timing and content. - -**Monitor execution stats** — Check completion rates to identify where contacts drop off. - -**Use meaningful names** — Name steps clearly: "Send Welcome Email" not "Step 1". - -**Set appropriate timeouts** — For "Wait for Event" steps, choose realistic timeouts based on user behavior. - -**Handle both paths** — Every condition and wait should have both success and failure paths defined. - -## Next Steps - -- [Track events](/guides/events) to trigger workflows -- [Create segments](/guides/segments) for segment-based triggers -- [Build templates](/guides/templates) to use in workflow emails -- [Set up webhooks](/guides/webhooks) for external integrations diff --git a/apps/wiki/content/docs/meta.json b/apps/wiki/content/docs/meta.json index c596140..4492023 100644 --- a/apps/wiki/content/docs/meta.json +++ b/apps/wiki/content/docs/meta.json @@ -7,9 +7,7 @@ "tutorials", "---Core Concepts---", "concepts", - "---Guides---", - "guides", - "---Automation Patterns---", + "---Automation---", "automation-patterns", "---API Reference---", "api-reference", diff --git a/apps/wiki/content/docs/self-hosting/database-setup.mdx b/apps/wiki/content/docs/self-hosting/database-setup.mdx deleted file mode 100644 index 01a04e8..0000000 --- a/apps/wiki/content/docs/self-hosting/database-setup.mdx +++ /dev/null @@ -1,228 +0,0 @@ ---- -title: Database Setup -description: Configure and manage your PostgreSQL database ---- - -## Prerequisites - -- PostgreSQL 14 or higher -- Database created -- Database user with permissions - -## Installation - -### Using Docker - -```bash -docker run -d \ - --name plunk-postgres \ - -e POSTGRES_PASSWORD=your-password \ - -e POSTGRES_DB=plunk \ - -p 5432:5432 \ - -v postgres_data:/var/lib/postgresql/data \ - postgres:14 -``` - -### Using Package Manager - -#### Ubuntu/Debian - -```bash -sudo apt update -sudo apt install postgresql postgresql-contrib -``` - -#### macOS - -```bash -brew install postgresql@14 -brew services start postgresql@14 -``` - -## Database Creation - -```sql -CREATE DATABASE plunk; -CREATE USER plunk WITH ENCRYPTED PASSWORD 'your-password'; -GRANT ALL PRIVILEGES ON DATABASE plunk TO plunk; -``` - -## Connection String - -```bash -DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk" -DIRECT_DATABASE_URL="postgresql://plunk:your-password@localhost:5432/plunk" -``` - -## Running Migrations - -### Development - -```bash -yarn workspace @plunk/db migrate:dev -``` - -### Production - -```bash -yarn workspace @plunk/db migrate:prod -``` - -## Database Schema - -Plunk uses Prisma ORM. The schema is located at: -``` -packages/db/prisma/schema.prisma -``` - -### Core Tables - -- **User**: User accounts -- **Project**: Workspaces/tenants -- **Membership**: User-project relationships -- **Contact**: Email contacts -- **Template**: Email templates -- **Campaign**: Email campaigns -- **Workflow**: Automated sequences -- **Email**: Email tracking -- **Event**: Custom events - -## Indexes - -Important indexes for performance: - -```sql --- Contact email index (unique per project) -CREATE INDEX idx_contact_email ON "Contact" (email, "projectId"); - --- Contact subscription index -CREATE INDEX idx_contact_subscribed ON "Contact" (subscribed); - --- Email tracking indexes -CREATE INDEX idx_email_contact ON "Email" ("contactId"); -CREATE INDEX idx_email_created ON "Email" ("createdAt"); - --- Event indexes -CREATE INDEX idx_event_contact ON "Event" ("contactId"); -CREATE INDEX idx_event_name ON "Event" (event); -``` - -## Performance Tuning - -### For Large Datasets (1M+ contacts) - -```sql --- Increase shared buffers (25% of RAM) -ALTER SYSTEM SET shared_buffers = '2GB'; - --- Increase work memory -ALTER SYSTEM SET work_mem = '50MB'; - --- Increase maintenance work memory -ALTER SYSTEM SET maintenance_work_mem = '512MB'; - --- Enable parallel queries -ALTER SYSTEM SET max_parallel_workers_per_gather = 4; - --- Reload configuration -SELECT pg_reload_conf(); -``` - -### Vacuum and Analyze - -Run regularly for optimal performance: - -```bash -# Manual vacuum -vacuumdb --analyze --verbose plunk - -# Auto-vacuum (enabled by default) -``` - -## Backup and Restore - -### Backup - -```bash -pg_dump -U plunk -d plunk > backup-$(date +%Y%m%d).sql -``` - -### Restore - -```bash -psql -U plunk -d plunk < backup.sql -``` - -### Automated Backups - -Set up cron job: - -```bash -0 2 * * * pg_dump -U plunk plunk > /backups/plunk-$(date +\%Y\%m\%d).sql -``` - -## Monitoring - -### Connection Count - -```sql -SELECT count(*) FROM pg_stat_activity; -``` - -### Database Size - -```sql -SELECT pg_size_pretty(pg_database_size('plunk')); -``` - -### Table Sizes - -```sql -SELECT - schemaname, - tablename, - pg_size_pretty(pg_total_relation_size(schemaname || '.' || tablename)) AS size -FROM pg_tables -WHERE schemaname = 'public' -ORDER BY pg_total_relation_size(schemaname || '.' || tablename) DESC; -``` - -### Slow Queries - -```sql -SELECT - query, - calls, - total_time, - mean_time -FROM pg_stat_statements -ORDER BY mean_time DESC -LIMIT 10; -``` - -## Troubleshooting - -### Connection refused - -Check PostgreSQL is running: -```bash -sudo systemctl status postgresql -``` - -### Authentication failed - -Verify credentials in connection string. - -### Too many connections - -Increase max_connections: -```sql -ALTER SYSTEM SET max_connections = 200; -SELECT pg_reload_conf(); -``` - -## Next Steps - -- [Configure email delivery](/self-hosting/email-setup) -- [Deploy with Docker](/self-hosting/docker) -- [Environment variables](/self-hosting/environment-variables) diff --git a/apps/wiki/content/docs/self-hosting/docker.mdx b/apps/wiki/content/docs/self-hosting/docker.mdx index 14aaf7d..f363d8b 100644 --- a/apps/wiki/content/docs/self-hosting/docker.mdx +++ b/apps/wiki/content/docs/self-hosting/docker.mdx @@ -1,394 +1,82 @@ --- title: Docker Deployment -description: Deploy Plunk with Docker Compose +description: Deploy with Docker Compose --- -## Prerequisites - -- Docker installed -- Docker Compose installed -- Git installed - ## Quick Start -### 1. Clone Repository - ```bash git clone https://github.com/useplunk/plunk.git cd plunk -``` - -### 2. Copy Environment File - -```bash cp .env.self-host.example .env -``` - -### 3. Configure Environment - -Edit `.env` and configure required variables: - -```bash -# Database Password (PostgreSQL) -DB_PASSWORD="changeme123" - -# JWT Secret (generate with: openssl rand -base64 32) -JWT_SECRET="your-secret-here" - -# Domains (for subdomain-based routing) -API_DOMAIN="api.localhost" -DASHBOARD_DOMAIN="app.localhost" -LANDING_DOMAIN="www.localhost" -WIKI_DOMAIN="docs.localhost" - -# Set to 'true' for HTTPS in production -USE_HTTPS="false" - -# AWS SES (for email sending) -AWS_SES_REGION="us-east-1" -AWS_SES_ACCESS_KEY_ID="your-access-key" -AWS_SES_SECRET_ACCESS_KEY="your-secret-key" -SES_CONFIGURATION_SET="plunk-configuration-set" - -# S3-compatible storage (Minio is included by default) -# Leave defaults unless using external S3 -S3_ENDPOINT="http://minio:9000" -S3_ACCESS_KEY_ID="plunk" -S3_ACCESS_KEY_SECRET="plunkminiopass" -S3_BUCKET="uploads" -S3_PUBLIC_URL="http://localhost:9000/uploads" -S3_FORCE_PATH_STYLE="true" -``` - -### 4. Start Services - -```bash +# Edit .env (see Environment Variables) docker compose up -d ``` -This starts: -- PostgreSQL database -- Redis -- Minio (S3-compatible storage) -- Plunk application (all services in one container with nginx) - - API server - - Worker process - - Web dashboard - - Landing page - - Documentation +See [Environment Variables](/self-hosting/environment-variables) for all configuration options. -### 5. Access Services +## Services -The services are available at the configured domains: -- **Dashboard**: `http://app.localhost` (or your configured domain) -- **API**: `http://api.localhost` -- **Landing**: `http://www.localhost` -- **Docs**: `http://docs.localhost` -- **Minio Console**: `http://localhost:9001` +| Service | Purpose | +|---------|---------| +| `plunk` | All apps + nginx (API, Web, Landing, Wiki, SMTP) | +| `postgres` | PostgreSQL 16 database | +| `redis` | Redis 7 queue | +| `minio` | S3-compatible storage | +| `ntfy` | Notifications | -Create your first account and start sending emails! +## Ports -## Docker Compose Configuration +| Port | Service | +|------|---------| +| 80 | Nginx (HTTP) | +| 465 | SMTP (implicit TLS) | +| 587 | SMTP (STARTTLS) | +| 9000 | Minio API | +| 9001 | Minio Console | -The `docker-compose.yml` file uses the pre-built Plunk image from GitHub Container Registry: +## Running Individual Services + +Set `SERVICE` environment variable: + +```bash +SERVICE=api # API only +SERVICE=worker # Worker only +SERVICE=web # Dashboard only +SERVICE=all # Everything (default) +``` + +## SMTP TLS Certificates + +For TLS on ports 465/587, provide certificates via one of these methods: + +### Traefik acme.json (Dokploy, Coolify) + +Mount the `acme.json` file and set `SMTP_DOMAIN`: ```yaml -version: '3.8' - -services: - postgres: - image: postgres:16-alpine - container_name: plunk-postgres - restart: unless-stopped - environment: - POSTGRES_DB: plunk - POSTGRES_USER: plunk - POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123} - volumes: - - postgres_data:/var/lib/postgresql/data - networks: - - plunk - - redis: - image: redis:7-alpine - container_name: plunk-redis - restart: unless-stopped - command: redis-server --appendonly yes - volumes: - - redis_data:/data - networks: - - plunk - - minio: - image: minio/minio:latest - container_name: plunk-minio - restart: unless-stopped - command: server /data --console-address ":9001" - environment: - MINIO_ROOT_USER: ${MINIO_ROOT_USER:-plunk} - MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-plunkminiopass} - volumes: - - minio_data:/data - ports: - - "9000:9000" # API - - "9001:9001" # Console - networks: - - plunk - - plunk: - image: ghcr.io/useplunk/plunk:latest - container_name: plunk - restart: unless-stopped - environment: - SERVICE: all # Runs all services (API, Worker, Web, Landing, Wiki) - DATABASE_URL: postgresql://plunk:${DB_PASSWORD}@postgres:5432/plunk - REDIS_URL: redis://redis:6379 - JWT_SECRET: ${JWT_SECRET} - # Domain configuration for subdomain routing - API_DOMAIN: ${API_DOMAIN:-api.localhost} - DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN:-app.localhost} - LANDING_DOMAIN: ${LANDING_DOMAIN:-www.localhost} - WIKI_DOMAIN: ${WIKI_DOMAIN:-docs.localhost} - USE_HTTPS: ${USE_HTTPS:-false} - # AWS SES - AWS_SES_REGION: ${AWS_SES_REGION} - AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID} - AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY} - # S3/Minio storage - S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} - S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-plunk} - S3_ACCESS_KEY_SECRET: ${S3_ACCESS_KEY_SECRET:-plunkminiopass} - ports: - - "465:465" # SMTP (implicit TLS) - - "587:587" # SMTP (STARTTLS) - depends_on: - - postgres - - redis - - minio - networks: - - plunk - +environment: + SMTP_DOMAIN: "smtp.yourdomain.com" volumes: - postgres_data: - redis_data: - minio_data: - plunk_data: - -networks: - plunk: - driver: bridge + - /path/to/acme.json:/certs/acme.json:ro ``` -**Note**: The Plunk image contains all applications (API, Worker, Web, Landing, Wiki) and uses nginx for subdomain-based routing. +Plunk automatically extracts the certificate for `SMTP_DOMAIN` from acme.json. -## Production Deployment +### PEM Files -### Using Pre-built Image +Mount certificate files directly: -The easiest way to deploy is using the pre-built image from GitHub Container Registry: - -```bash -docker pull ghcr.io/useplunk/plunk:latest -docker compose up -d +```yaml +volumes: + - /path/to/privkey.pem:/certs/privkey.pem:ro + - /path/to/fullchain.pem:/certs/fullchain.pem:ro ``` -### Building Your Own Image +If no certificates are mounted, SMTP runs without TLS. -If you want to build from source: +## Building from Source ```bash docker build -t plunk:custom . ``` - -Then update `docker-compose.yml` to use your custom image: -```yaml -plunk: - image: plunk:custom - # ... rest of configuration -``` - -### Security Hardening - -1. **Use strong passwords** - ```bash - DB_PASSWORD=$(openssl rand -base64 32) - JWT_SECRET=$(openssl rand -base64 32) - ``` - -2. **Use HTTPS** - - Set `USE_HTTPS=true` in your `.env` - - Set up reverse proxy (Traefik, Caddy, or nginx) - - Configure SSL certificates (Let's Encrypt) - -3. **Configure domains** - ```bash - API_DOMAIN=api.yourdomain.com - DASHBOARD_DOMAIN=app.yourdomain.com - LANDING_DOMAIN=www.yourdomain.com - WIKI_DOMAIN=docs.yourdomain.com - USE_HTTPS=true - ``` - -4. **Restrict network access** - - Don't expose database and Redis ports publicly - - Use internal Docker networks - - Only expose port 80/443 (via reverse proxy) and SMTP ports - -5. **Regular backups** - - Database backups (automated via cron) - - Minio data backups - -### Scaling - -The Plunk image runs all services in a single container by default. For higher scale: - -#### Separate Services - -You can run services separately by setting the `SERVICE` environment variable: - -```yaml -# API only -plunk-api: - image: ghcr.io/useplunk/plunk:latest - environment: - SERVICE: api - # ... configuration - -# Worker only -plunk-worker: - image: ghcr.io/useplunk/plunk:latest - environment: - SERVICE: worker - # ... configuration - -# Web only -plunk-web: - image: ghcr.io/useplunk/plunk:latest - environment: - SERVICE: web - # ... configuration -``` - -#### Scale Workers - -For higher email throughput, run multiple worker containers: - -```bash -docker compose up -d --scale plunk-worker=3 -``` - -#### External Services - -For production at scale, use managed services: -- Managed PostgreSQL (AWS RDS, DigitalOcean, Supabase) -- Managed Redis (AWS ElastiCache, Redis Cloud, Upstash) -- AWS S3 (instead of Minio) - -## Maintenance - -### View Logs - -```bash -# All services -docker compose logs -f - -# Specific service -docker compose logs -f plunk -docker compose logs -f postgres -``` - -### Restart Services - -```bash -docker compose restart plunk -``` - -### Update Plunk - -Pull the latest image and restart: - -```bash -docker compose pull plunk -docker compose up -d plunk -``` - -If you built from source: -```bash -git pull -docker build -t plunk:custom . -docker compose up -d -``` - -### Backup Database - -```bash -docker compose exec postgres pg_dump -U plunk plunk > backup-$(date +%Y%m%d).sql -``` - -### Restore Database - -```bash -docker compose exec -T postgres psql -U plunk plunk < backup.sql -``` - -### Backup Minio Data - -```bash -docker compose exec minio mc alias set local http://localhost:9000 plunk plunkminiopass -docker compose exec minio mc mirror local/uploads /backups/minio -``` - -## Troubleshooting - -### Services won't start - -Check logs: -```bash -docker compose logs plunk -``` - -### Database connection errors - -Ensure DATABASE_URL is correct and database is running: -```bash -docker compose ps postgres -docker compose exec postgres psql -U plunk -d plunk -c "SELECT 1;" -``` - -### Worker not processing jobs - -Check Plunk container logs for worker output: -```bash -docker compose logs plunk | grep worker -``` - -Verify Redis connection: -```bash -docker compose exec redis redis-cli PING -``` - -### Cannot access services - -Check that your domains resolve correctly: -```bash -# For local development with *.localhost domains, these should work automatically -# For production domains, ensure DNS is configured correctly -curl http://api.localhost -curl http://app.localhost -``` - -### Minio not accessible - -Check Minio is running: -```bash -docker compose ps minio -docker compose logs minio -``` - -Access Minio console at `http://localhost:9001` with credentials from `.env`. - -## Next Steps - -- [Configure environment variables](/self-hosting/environment-variables) -- [Set up email delivery](/self-hosting/email-setup) -- [Database setup and migrations](/self-hosting/database-setup) diff --git a/apps/wiki/content/docs/self-hosting/email-setup.mdx b/apps/wiki/content/docs/self-hosting/email-setup.mdx index e9039a5..bab5a05 100644 --- a/apps/wiki/content/docs/self-hosting/email-setup.mdx +++ b/apps/wiki/content/docs/self-hosting/email-setup.mdx @@ -1,218 +1,92 @@ --- -title: Email Setup (AWS SES) -description: Configure AWS SES for email delivery +title: AWS SES Setup +description: Configure email delivery --- -## Prerequisites +## 1. Create IAM User -- AWS account -- AWS SES access -- Domain ownership (for custom domains) +1. Go to IAM Console → Users → Create user +2. Name: `plunk-ses` +3. Attach a custom policy with required permissions (see below) +4. Create access keys → Save credentials -## AWS SES Setup +### Required IAM Policy -### 1. Create AWS Account +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ses:SetIdentityMailFromDomain", + "ses:GetIdentityDkimAttributes", + "ses:SendRawEmail", + "ses:GetIdentityVerificationAttributes", + "ses:VerifyDomainDkim", + "ses:ListIdentities", + "ses:SetIdentityFeedbackForwardingEnabled" + ], + "Resource": "*" + } + ] +} +``` -Sign up at [aws.amazon.com](https://aws.amazon.com) +## 2. Request Production Access -### 2. Request Production Access +SES starts in sandbox mode (verified addresses only). -By default, SES is in sandbox mode (limited to verified addresses). - -1. Go to AWS SES Console +1. Go to SES Console 2. Click "Request production access" -3. Fill out the form -4. Wait for approval (usually 24-48 hours) +3. Wait for approval (24-48 hours) -### 3. Create IAM User +## 3. Verify Domain -Create dedicated IAM user for Plunk: +1. SES Console → Verified Identities → Create identity +2. Choose "Domain" → Enter your domain +3. Add DNS records provided by AWS +4. Wait for verification -1. Go to IAM Console -2. Create new user: "plunk-ses" -3. Attach policy: `AmazonSESFullAccess` -4. Create access keys -5. Save access key ID and secret key +## 4. Enable DKIM (Recommended) -### 4. Configure Environment Variables +1. SES Console → Verified Identities → Your domain +2. Enable "Easy DKIM" +3. Add the 3 CNAME records to your DNS + +## 5. Create SNS Topic + +1. Go to SNS Console → Topics → Create topic +2. Type: Standard +3. Name: `plunk-ses-events` +4. Create topic +5. Create subscription: + - Protocol: HTTPS + - Endpoint: `https://api.yourdomain.com/webhooks/sns` +6. Plunk automatically confirms the subscription. If it fails, check your logs for the confirmation URL. + +## 6. Create Configuration Sets + +### Tracking Configuration Set + +1. SES Console → Configuration sets → Create set +2. Name: `plunk-tracking` +3. Add event destination: + - Name: `sns-events` + - Event types: **Sends, Deliveries, Opens, Clicks, Bounces, Complaints** + - Destination: SNS → Select `plunk-ses-events` topic + +### No-Tracking Configuration Set + +1. Create another set named `plunk-no-tracking` +2. Add event destination with only: **Sends, Deliveries, Bounces, Complaints** + +## 7. Configure Environment ```bash AWS_SES_REGION="us-east-1" -AWS_SES_ACCESS_KEY_ID="your-access-key-id" -AWS_SES_SECRET_ACCESS_KEY="your-secret-access-key" -``` - -## Verify Email Addresses - -### Single Email - -```bash -aws ses verify-email-identity --email-address hello@yourdomain.com -``` - -Check your inbox and click verification link. - -### Domain Verification - -1. Go to SES Console → Verified Identities -2. Click "Create identity" -3. Choose "Domain" -4. Enter your domain: `yourdomain.com` -5. Add DNS records provided by AWS - -DNS records (example): -``` -Type: TXT -Name: _amazonses.yourdomain.com -Value: provided-by-aws -``` - -Wait for verification (up to 72 hours). - -## Configuration Sets - -Create configuration sets for tracking: - -### 1. Tracking Configuration Set - -```bash -aws ses create-configuration-set \ - --configuration-set-name plunk-tracking -``` - -### 2. No-Tracking Configuration Set - -```bash -aws ses create-configuration-set \ - --configuration-set-name plunk-no-tracking -``` - -### 3. Update Environment - -```bash +AWS_SES_ACCESS_KEY_ID="your-access-key" +AWS_SES_SECRET_ACCESS_KEY="your-secret-key" SES_CONFIGURATION_SET="plunk-tracking" SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking" ``` - -## SNS for Email Events - -Set up SNS to receive email events (opens, clicks, bounces): - -### 1. Create SNS Topic - -```bash -aws sns create-topic --name plunk-email-events -``` - -### 2. Subscribe Plunk Webhook - -```bash -aws sns subscribe \ - --topic-arn arn:aws:sns:us-east-1:123456789:plunk-email-events \ - --protocol https \ - --notification-endpoint https://api.yourdomain.com/webhooks/sns -``` - -### 3. Configure SES Event Publishing - -1. Go to SES Console → Configuration Sets -2. Select `plunk-tracking` -3. Add destination → SNS -4. Select your SNS topic -5. Enable events: Delivery, Bounce, Complaint, Open, Click - -## DKIM Setup - -Enable DKIM signing for better deliverability: - -1. Go to SES Console → Verified Identities -2. Select your domain -3. Enable "Easy DKIM" -4. Add CNAME records to your DNS - -``` -Type: CNAME -Name: xxx._domainkey.yourdomain.com -Value: xxx.dkim.amazonses.com -``` - -Repeat for all 3 CNAME records provided. - -## Testing Email Delivery - -```bash -curl -X POST {{API_URL}}/v1/send \ - -H "Authorization: Bearer sk_your_secret_key" \ - -H "Content-Type: application/json" \ - -d '{ - "to": "test@example.com", - "subject": "Test Email", - "body": "Hello from Plunk!", - "subscribed": true - }' -``` - -Check AWS SES Console → Email sending → Sending statistics. - -## Monitoring - -### SES Dashboard - -View in AWS Console: -- Sends -- Bounces -- Complaints -- Reputation - -### CloudWatch Metrics - -Set up alarms for: -- Bounce rate > 5% -- Complaint rate > 0.1% -- Send quota utilization > 80% - -## Troubleshooting - -### Emails in sandbox mode only - -Request production access via SES Console. - -### Domain not verified - -Check DNS records and wait for propagation (up to 72 hours). - -### High bounce rate - -- Clean your contact list -- Use double opt-in -- Remove hard bounces immediately - -### Low reputation score - -- Reduce bounce and complaint rates -- Send only to engaged users -- Implement feedback loops - -## Best Practices - -1. **Warm up gradually**: Start with low volume, increase slowly -2. **Monitor metrics**: Watch bounces and complaints closely -3. **Clean lists**: Remove inactive and bounced addresses -4. **Use DKIM**: Enable for better deliverability -5. **Segment sends**: Don't send same content to everyone - -## Cost Optimization - -- First 62,000 emails/month: **FREE** (from EC2) -- Additional: **$0.10 per 1,000 emails** -- Data transfer: **$0.12 per GB** - -Example: -- 100,000 emails/month: ~$3.80/month -- 1,000,000 emails/month: ~$94/month - -## Next Steps - -- [Complete environment setup](/self-hosting/environment-variables) -- [Deploy with Docker](/self-hosting/docker) -- [Send your first email](/getting-started/quick-start) diff --git a/apps/wiki/content/docs/self-hosting/environment-variables.mdx b/apps/wiki/content/docs/self-hosting/environment-variables.mdx index 202f8e2..d90fe4c 100644 --- a/apps/wiki/content/docs/self-hosting/environment-variables.mdx +++ b/apps/wiki/content/docs/self-hosting/environment-variables.mdx @@ -1,182 +1,66 @@ --- title: Environment Variables -description: Complete environment variable reference +description: Configuration reference --- -## Required Variables - -### Database +## Required ```bash -# PostgreSQL connection string -DATABASE_URL="postgresql://user:password@host:5432/plunk" - -# Direct connection (for Prisma migrations) -DIRECT_DATABASE_URL="postgresql://user:password@host:5432/plunk" -``` - -### Redis - -```bash -# Redis connection URL -REDIS_URL="redis://host:6379" -``` - -### Security - -```bash -# JWT signing secret (generate with: openssl rand -base64 32) -JWT_SECRET="your-secret-here" -``` - -### AWS SES (Email Delivery) - -```bash -AWS_SES_REGION="us-east-1" -AWS_SES_ACCESS_KEY_ID="your-access-key" -AWS_SES_SECRET_ACCESS_KEY="your-secret-key" - -# SES Configuration Sets -SES_CONFIGURATION_SET="plunk-tracking" -SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking" -``` - -### Application URLs - -```bash -# Protocol configuration (auto-generates URIs with http:// or https://) -USE_HTTPS="false" # Set to "true" for HTTPS in production - -# Application URIs (auto-generated from domains if not set) -API_URI="https://api.yourdomain.com" -DASHBOARD_URI="https://app.yourdomain.com" -LANDING_URI="https://www.yourdomain.com" - -# For Next.js (build time) -NEXT_PUBLIC_API_URI="https://api.yourdomain.com" -NEXT_PUBLIC_DASHBOARD_URI="https://app.yourdomain.com" -NEXT_PUBLIC_LANDING_URI="https://www.yourdomain.com" -``` - -**Note**: When using domain-based configuration (e.g., `API_DOMAIN=api.yourdomain.com`), the application URIs are automatically generated. Set `USE_HTTPS=true` to use HTTPS protocol, otherwise HTTP will be used by default. You can also manually set the full URIs to override the auto-generation. - -## Optional Variables - -### Plunk API - -If you're using the email package's `sendEmail` function to send emails via the Plunk API: - -```bash -# Plunk API Key (obtained from dashboard) -PLUNK_API_KEY="sk_your_secret_key" -``` - -### S3-Compatible Storage (Minio) - -**Note**: When using Docker Compose, Minio is included and these variables are automatically configured with defaults. You typically don't need to set these unless you want to use external S3 storage. - -```bash -# Only configure if NOT using the bundled Minio -S3_ENDPOINT="http://minio:9000" # Default: uses bundled Minio -S3_ACCESS_KEY_ID="plunk" # Default: plunk -S3_ACCESS_KEY_SECRET="plunkminiopass" # Default: plunkminiopass -S3_BUCKET="uploads" # Default: uploads -S3_PUBLIC_URL="http://localhost:9000/uploads" # Default: Minio URL -S3_FORCE_PATH_STYLE="true" # Required for Minio -``` - -### OAuth Providers - -```bash -# GitHub OAuth -GITHUB_OAUTH_CLIENT="your-client-id" -GITHUB_OAUTH_SECRET="your-client-secret" - -# Google OAuth -GOOGLE_OAUTH_CLIENT="your-client-id" -GOOGLE_OAUTH_SECRET="your-client-secret" -``` - -### Stripe Billing - -```bash -STRIPE_SK="sk_test_..." -STRIPE_WEBHOOK_SECRET="whsec_..." - -# Stripe Products -STRIPE_PRICE_ONBOARDING="price_..." -STRIPE_PRICE_EMAIL_USAGE="price_..." - -# Stripe Metering -STRIPE_METER_EVENT_NAME="email_sent" -``` - -### Internal - -```bash -# Node environment -NODE_ENV="production" -``` - -## Example .env File - -```bash -# Database -DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk" -DIRECT_DATABASE_URL="postgresql://postgres:password@localhost:5432/plunk" - -# Redis -REDIS_URL="redis://localhost:6379" - # Security -JWT_SECRET="generated-secret-here" +JWT_SECRET="your-secret" # openssl rand -base64 32 +DB_PASSWORD="your-password" + +# Database (auto-configured in Docker) +DATABASE_URL="postgresql://plunk:password@postgres:5432/plunk" +REDIS_URL="redis://redis:6379" # AWS SES AWS_SES_REGION="us-east-1" -AWS_SES_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE" -AWS_SES_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" +AWS_SES_ACCESS_KEY_ID="your-key" +AWS_SES_SECRET_ACCESS_KEY="your-secret" SES_CONFIGURATION_SET="plunk-tracking" -SES_CONFIGURATION_SET_NO_TRACKING="plunk-no-tracking" - -# Protocol & URLs -USE_HTTPS="false" -API_URI="http://localhost:3001" -DASHBOARD_URI="http://localhost:3000" -LANDING_URI="http://localhost:3002" - -# Next.js Public URLs -NEXT_PUBLIC_API_URI="http://localhost:3001" -NEXT_PUBLIC_DASHBOARD_URI="http://localhost:3000" -NEXT_PUBLIC_LANDING_URI="http://localhost:3002" - -# Node Environment -NODE_ENV="development" ``` -## Generating Secrets - -### JWT Secret +## Domains ```bash -openssl rand -base64 32 +# Subdomains for routing +API_DOMAIN="api.yourdomain.com" +DASHBOARD_DOMAIN="app.yourdomain.com" +LANDING_DOMAIN="www.yourdomain.com" +WIKI_DOMAIN="docs.yourdomain.com" +SMTP_DOMAIN="smtp.yourdomain.com" + +# Protocol +USE_HTTPS="true" # false for local dev ``` -### Strong Passwords +## Storage (Minio) + +Defaults work with bundled Minio. Only set for external S3: ```bash -openssl rand -base64 24 +S3_ENDPOINT="http://minio:9000" +S3_ACCESS_KEY_ID="plunk" +S3_ACCESS_KEY_SECRET="plunkminiopass" +S3_BUCKET="uploads" +S3_PUBLIC_URL="http://localhost:9000/uploads" +S3_FORCE_PATH_STYLE="true" ``` -## Security Best Practices +## Optional -1. **Never commit secrets** to version control -2. **Use environment-specific files** (.env.production, .env.development) -3. **Rotate secrets regularly** -4. **Use secret management** (AWS Secrets Manager, HashiCorp Vault) in production -5. **Limit access** to production environment variables +```bash +# OAuth +GITHUB_OAUTH_CLIENT="client-id" +GITHUB_OAUTH_SECRET="client-secret" +GOOGLE_OAUTH_CLIENT="client-id" +GOOGLE_OAUTH_SECRET="client-secret" -## Next Steps +# Stripe +STRIPE_SK="sk_..." +STRIPE_WEBHOOK_SECRET="whsec_..." -- [Set up database](/self-hosting/database-setup) -- [Configure email delivery](/self-hosting/email-setup) -- [Deploy with Docker](/self-hosting/docker) +# Notifications +NTFY_URL="http://ntfy/plunk-notifications" +``` diff --git a/apps/wiki/content/docs/self-hosting/introduction.mdx b/apps/wiki/content/docs/self-hosting/introduction.mdx index 6e84799..44c32a1 100644 --- a/apps/wiki/content/docs/self-hosting/introduction.mdx +++ b/apps/wiki/content/docs/self-hosting/introduction.mdx @@ -1,151 +1,35 @@ --- -title: Self-Hosting Introduction +title: Self-Hosting description: Deploy Plunk on your own infrastructure --- -## Overview - -Plunk is fully open-source and can be self-hosted on your own infrastructure. This gives you complete control over your data, email delivery, and infrastructure costs. - ## Requirements -### System Requirements - -- **Node.js**: Version 20 or higher -- **PostgreSQL**: Version 14 or higher -- **Redis**: Version 6 or higher -- **Docker** (recommended): For easy deployment - -### AWS Services - -- **AWS SES** (Required): For email sending -- **AWS S3** (Optional): For asset storage (Minio is included by default) - -### Minimum Server Specs - -For small to medium usage (up to 100K contacts): -- **CPU**: 1-2 cores -- **RAM**: 2GB -- **Storage**: 10GB (grows with contact data) - -For larger scale (1M+ contacts): -- **CPU**: 2-4 cores -- **RAM**: 4GB -- **Storage**: 50GB+ - -## Architecture Components - -Plunk uses a containerized architecture with the following components: - -### 1. Plunk Application Container -Single container running all application services (API, Worker, Web, Landing, Wiki) with nginx reverse proxy for subdomain-based routing. - -**Resources**: 1GB RAM, 1 CPU core (can scale up as needed) - -**Services included**: -- **API Server**: Express.js application handling HTTP requests -- **Worker Process**: BullMQ worker processing background jobs -- **Web Dashboard**: Next.js application for the UI -- **Landing Page**: Marketing website -- **Documentation**: Wiki/docs site -- **SMTP Relay**: Email relay server (ports 465, 587) - -**Note**: Services can be run separately by setting the `SERVICE` environment variable (`api`, `worker`, `web`, `landing`, `wiki`, or `all`). - -### 2. PostgreSQL Database -Stores all data (contacts, campaigns, workflows, etc.). - -**Resources**: 512MB-1GB RAM, SSD storage recommended - -### 3. Redis -Queue system for background jobs (BullMQ). - -**Resources**: 256MB-512MB RAM - -### 4. Minio (S3-compatible Storage) -Object storage for file uploads and assets. - -**Resources**: 256MB-512MB RAM, storage for uploaded files - -## Deployment Options - -### Docker Compose (Recommended) - -Easiest way to get started. Includes all services pre-configured. - -[View Docker Guide](/self-hosting/docker) - -### Kubernetes - -For production deployments at scale. - -### Manual Deployment - -Deploy each component separately on your infrastructure. +- Docker and Docker Compose +- AWS SES account (for sending emails) +- Domain name (for production) ## Quick Start -1. Clone the repository -2. Copy `.env.self-host.example` to `.env` -3. Configure environment variables (see [Environment Variables](/self-hosting/environment-variables)) -4. Run `docker compose up -d` -5. Access dashboard at `http://app.localhost` (or your configured domain) +```bash +git clone https://github.com/useplunk/plunk.git +cd plunk +cp .env.self-host.example .env +# Edit .env with your settings +docker compose up -d +``` -## What's Included +## Access -- ✅ Full API server -- ✅ Worker process -- ✅ Web dashboard -- ✅ PostgreSQL database -- ✅ Redis queue -- ✅ All features (campaigns, workflows, segments) -- ✅ No feature limitations -- ✅ No phone-home telemetry - -## What's Not Included - -- ❌ Managed infrastructure -- ❌ Automatic updates -- ❌ Support (community only) -- ❌ SLA guarantees - -## Cost Considerations - -### AWS SES Costs - -- First 62,000 emails/month: **FREE** (when sent from EC2) -- Additional emails: **$0.10 per 1,000 emails** - -### Infrastructure Costs - -**All-in-One VPS** (recommended for small to medium usage): -- **Single VPS**: $5-12/month (2GB RAM, services like DigitalOcean, Hetzner, Vultr) - - Runs all containers (Plunk, PostgreSQL, Redis, Minio) -- **AWS SES**: $0-10/month (depends on volume) -- **Storage**: Included in VPS - -**Total**: ~$5-20/month for self-hosting - -**Managed Services** (for larger scale or production): -- **VPS**: $10-20/month (4GB RAM for Plunk application) -- **Managed PostgreSQL**: $10-15/month (512MB-1GB) -- **Managed Redis**: $5-10/month (256MB-512MB) -- **AWS SES**: $10-50/month (depends on volume) -- **AWS S3**: $1-5/month (if not using Minio) - -**Total**: ~$35-100/month for production with managed services - -## Support - -### Community Support - -- GitHub Issues -- Community Forum -- Documentation +| Service | URL | +|---------|-----| +| Dashboard | http://app.localhost | +| API | http://api.localhost | +| Docs | http://docs.localhost | +| Minio Console | http://localhost:9001 | ## Next Steps -- [Deploy with Docker](/self-hosting/docker) -- [Configure environment variables](/self-hosting/environment-variables) -- [Set up database](/self-hosting/database-setup) -- [Configure email delivery](/self-hosting/email-setup) +- [Docker Deployment](/self-hosting/docker) — Configuration details +- [Environment Variables](/self-hosting/environment-variables) — All settings +- [AWS SES Setup](/self-hosting/email-setup) — Email configuration diff --git a/apps/wiki/content/docs/self-hosting/meta.json b/apps/wiki/content/docs/self-hosting/meta.json index e5ed9a4..6f55561 100644 --- a/apps/wiki/content/docs/self-hosting/meta.json +++ b/apps/wiki/content/docs/self-hosting/meta.json @@ -1,4 +1,4 @@ { "title": "Self-Hosting", - "pages": ["introduction", "docker", "environment-variables", "database-setup", "email-setup"] + "pages": ["introduction", "docker", "environment-variables", "email-setup"] } diff --git a/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx b/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx deleted file mode 100644 index 1af9db8..0000000 --- a/apps/wiki/content/docs/tutorials/cart-abandonment-automation.mdx +++ /dev/null @@ -1,227 +0,0 @@ ---- -title: Cart Abandonment Recovery -description: Recover abandoned carts with automated emails -icon: ShoppingCart ---- - -## Overview - -Send automated recovery emails when users add items to cart but don't complete purchase. Uses a two-email sequence with a discount incentive. - -## Prerequisites - -- Track `cart_abandoned` and `purchase_completed` events from your app -- Create two email templates in dashboard - -## Track cart events - -### Cart abandoned - -When user adds items but leaves without purchasing: - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'cart_abandoned', - email: user.email, - data: { - cartTotal: cart.total, - cartUrl: `https://yourstore.com/cart/${cart.id}`, - itemCount: cart.items.length, - items: cart.items.map(i => ({ - name: i.product.name, - price: i.price, - quantity: i.quantity, - imageUrl: i.product.imageUrl - })) - } - }) -}); -``` - -### Purchase completed - -When user completes checkout: - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'purchase_completed', - email: user.email, - data: { - orderId: order.id, - total: order.total - } - }) -}); -``` - -## Build the workflow - -### 1. Create workflow - -Go to **Workflows** → **Create Workflow** - -- **Name:** Cart Abandonment Recovery -- **Trigger:** Event - `cart_abandoned` -- **Allow re-entry:** Yes (users can abandon multiple times) - -### 2. Add workflow steps - -``` -[Trigger: cart_abandoned] - ↓ -[Delay: 1 hour] - ↓ -[Send Email: Cart Reminder] - ↓ -[Wait for Event: purchase_completed, timeout: 23 hours] - ├─ Purchased → [Exit] - └─ Timeout → [Send Email: Cart Discount] → [Exit] -``` - -**Step-by-step:** - -1. **Delay** (1 hour) - - Duration: 1 - - Unit: Hours - -2. **Send Email** (Cart Reminder) - - Template: Cart Reminder - - Variables: (auto-populated from event data) - -3. **Wait for Event** - - Event: `purchase_completed` - - Timeout: 23 hours - - Connect two paths: - - **Event triggered** → Exit - - **Timeout** → Continue to discount - -4. **Send Email** (Cart Discount) - - Template: Cart Discount - - Variables: - - All cart data from event - - `discountedTotal`: Calculate in template or pass from backend - -5. **Exit** - -### 3. Enable workflow - -Toggle workflow to **ON** - -## Test the workflow - -### Manual test - -1. Go to **Workflows** → Cart Abandonment Recovery → **Executions** -2. Click **Create Execution** -3. Select test contact -4. Provide test data: - -```json -{ - "cartTotal": 149.99, - "cartUrl": "https://yourstore.com/cart/test123", - "itemCount": 2, - "items": [ - { - "name": "Product A", - "price": 79.99, - "quantity": 1, - "imageUrl": "https://cdn.example.com/product-a.jpg" - }, - { - "name": "Product B", - "price": 69.99, - "quantity": 1, - "imageUrl": "https://cdn.example.com/product-b.jpg" - } - ], - "discountedTotal": 134.99 -} -``` - -5. **Start Execution** - -Watch the execution run. For faster testing, temporarily set delay to 1 minute instead of 1 hour. - -### Live test - -1. Trigger `cart_abandoned` event with your email -2. Wait 1 hour (or 1 minute if testing with shorter delay) -3. Check for first email -4. Either: - - Complete purchase → workflow ends - - Wait 23 hours → receive discount email - -## Improve conversion - -### Add cart item images - -Pass product images in event data and display in email. Visual reminders increase clicks. - -### Personalize timing - -Test different delays: -- First email: 30min, 1hr, 2hr -- Second email: 12hr, 24hr, 48hr - -Monitor which timing drives best conversion. - -### Increase discount incrementally - -Second email could offer 10%, third email (if you add one) could offer 15%. - -### Segment by cart value - -Create separate workflows for high-value carts (e.g., >$200) with different messaging or larger discounts. - -### Track discount usage - -When user applies discount code, track event: - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'discount_applied', - email: user.email, - data: { - code: 'SAVE10', - source: 'cart_abandonment_email' - } - }) -}); -``` - -This lets you measure email-driven conversions. - -## Common issues - -**Email sends but cart is already purchased** — Add a condition before sending emails to check if purchase event already occurred. - -**Cart URL expired** — Ensure cart sessions last at least 48 hours, or regenerate cart from saved items. - -**Discount code doesn't work** — Verify code exists in your system before sending email. Auto-generate unique codes per user for better tracking. - -**Too many emails** — Users abandoning multiple carts quickly will enter workflow multiple times. Consider adding a delay condition or rate limiting. - -## Next steps - -- [Track more events](/tutorials/event-tracking-integration) for behavior-based workflows -- [Build segments](/tutorials/segment-based-targeting) for high-value cart abandoners -- [Use conditions](/automation-patterns/conditional-branching) for cart value-based logic diff --git a/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx b/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx deleted file mode 100644 index c3e403a..0000000 --- a/apps/wiki/content/docs/tutorials/event-tracking-integration.mdx +++ /dev/null @@ -1,475 +0,0 @@ ---- -title: Event Tracking Integration -description: Track user behavior to trigger workflows -icon: Activity ---- - -## Overview - -Track events from your application to trigger workflows and update contact data. Events like `user_signed_up`, `purchase_completed`, `feature_used` can start automated email sequences. - -## Get your public key - -1. Go to [Settings → General]({{DASHBOARD_URL}}/settings) -2. Copy your **Public Key** (starts with `pk_`) - -Public keys are safe to use in client-side code. - -## Basic event tracking - -### JavaScript (client-side) - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'button_clicked', - email: user.email, - data: { - buttonName: 'Get Started', - page: '/pricing' - } - }) -}); -``` - -### Node.js (server-side) - -```javascript -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'user_signed_up', - email: user.email, - data: { - name: user.name, - plan: 'free', - signupDate: new Date().toISOString() - } - }) -}); -``` - -### Python - -```python -import requests -import os - -requests.post('{{API_URL}}/v1/track', - headers={ - 'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}', - 'Content-Type': 'application/json' - }, - json={ - 'event': 'purchase_completed', - 'email': user.email, - 'data': { - 'orderId': order.id, - 'total': order.total, - 'items': order.items - } - } -) -``` - -## Common events to track - -### User lifecycle - -```javascript -// Signup -await trackEvent('user_signed_up', user.email, { - name: user.name, - source: 'google', - plan: 'free' -}); - -// Activation -await trackEvent('first_value_achieved', user.email, { - action: 'created_first_project', - timestamp: new Date().toISOString() -}); - -// Upgrade -await trackEvent('subscription_upgraded', user.email, { - fromPlan: 'free', - toPlan: 'premium', - mrr: 99 -}); - -// Churn -await trackEvent('subscription_cancelled', user.email, { - reason: user.cancellationReason, - cancelledAt: new Date().toISOString() -}); -``` - -### Product engagement - -```javascript -// Feature usage -await trackEvent('feature_used', user.email, { - featureName: 'data_export', - timestamp: new Date().toISOString() -}); - -// Content interaction -await trackEvent('video_watched', user.email, { - videoId: 'intro-101', - duration: 300, - completed: true -}); - -// Settings changes -await trackEvent('settings_updated', user.email, { - setting: 'notifications', - value: 'enabled' -}); -``` - -### E-commerce - -```javascript -// Cart -await trackEvent('cart_abandoned', user.email, { - cartId: cart.id, - cartTotal: cart.total, - items: cart.items.map(i => i.name) -}); - -// Purchase -await trackEvent('purchase_completed', user.email, { - orderId: order.id, - total: order.total, - paymentMethod: 'credit_card' -}); - -// Review -await trackEvent('review_submitted', user.email, { - productId: product.id, - rating: 5 -}); -``` - -## Event naming conventions - -**Use lowercase with underscores:** -- ✅ `user_signed_up` -- ✅ `purchase_completed` -- ❌ `UserSignedUp` -- ❌ `purchase-completed` - -**Be specific:** -- ✅ `trial_started` -- ❌ `event` - -**Use past tense:** -- ✅ `email_opened` -- ❌ `email_open` - -## Event data best practices - -**Keep data flat when possible:** - -```javascript -// Good -{ - name: 'John', - plan: 'premium', - mrr: 99 -} - -// Works but harder to use -{ - user: { - profile: { - name: 'John' - } - } -} -``` - -**Use consistent types:** - -```javascript -// Good - number for numeric values -{ total: 99.99 } - -// Bad - string for numeric values -{ total: "99.99" } -``` - -**Use ISO dates:** - -```javascript -// Good -{ signupDate: new Date().toISOString() } - -// Okay but less flexible -{ signupDate: '2024-03-15' } -``` - -## Integrate with React - -### Context provider - -```javascript -// EventTrackingContext.js -import { createContext, useContext } from 'react'; - -const EventTrackingContext = createContext(); - -export function EventTrackingProvider({ children }) { - const trackEvent = async (event, data = {}) => { - const user = getCurrentUser(); // Your auth logic - - if (!user?.email) return; - - await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event, - email: user.email, - data: { - name: user.name, - ...data - } - }) - }); - }; - - return ( - - {children} - - ); -} - -export const useEventTracking = () => useContext(EventTrackingContext); -``` - -### Use in components - -```javascript -import { useEventTracking } from './EventTrackingContext'; - -function UpgradeButton() { - const { trackEvent } = useEventTracking(); - - const handleUpgrade = async () => { - await upgradePlan('premium'); - - await trackEvent('plan_upgraded', { - plan: 'premium', - source: 'pricing_page' - }); - }; - - return ; -} -``` - -## Integrate with Next.js - -### Client component - -```javascript -'use client'; - -import { trackEvent } from '@/lib/plunk'; - -export function SignupForm() { - const handleSubmit = async (data) => { - const user = await createUser(data); - - // Track event - await trackEvent('user_signed_up', user.email, { - name: user.name, - source: 'homepage' - }); - }; - - return
...
; -} -``` - -### Server action - -```javascript -'use server'; - -import { trackEvent } from '@/lib/plunk'; - -export async function createProject(formData) { - const user = await getCurrentUser(); - const project = await db.projects.create({ - name: formData.get('name'), - userId: user.id - }); - - await trackEvent('project_created', user.email, { - projectId: project.id, - projectName: project.name - }); - - return project; -} -``` - -## Create a helper function - -```javascript -// lib/plunk.js -const PLUNK_PUBLIC_KEY = process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY; - -export async function trackEvent(event, email, data = {}) { - try { - const response = await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event, - email, - data - }) - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - - return await response.json(); - } catch (error) { - console.error('Failed to track event:', error); - // Don't throw - tracking shouldn't break your app - } -} -``` - -## Testing events - -### View tracked events - -1. Go to **Activity** in Plunk dashboard -2. Filter by event type -3. View event data payloads - -### Test locally - -```javascript -// Track a test event -await fetch('{{API_URL}}/v1/track', { - method: 'POST', - headers: { - 'Authorization': 'Bearer pk_your_key', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - event: 'test_event', - email: 'your-email@example.com', - data: { - test: true, - timestamp: new Date().toISOString() - } - }) -}); -``` - -Check Plunk Activity - event should appear within seconds. - -## Connect to workflows - -Once events are tracked, create workflows that trigger on them: - -1. **Workflows** → **Create Workflow** -2. Trigger: Event `user_signed_up` -3. Build your automation -4. Enable workflow - -Now when you track `user_signed_up`, the workflow runs automatically. - -## Performance considerations - -**Don't block user actions:** - -```javascript -// Good - fire and forget -handleClick() { - trackEvent('button_clicked', user.email); - // Don't await -} - -// Bad - user waits for tracking -async handleClick() { - await trackEvent('button_clicked', user.email); - // User has to wait -} -``` - -**Batch events for bulk operations:** - -```javascript -// If importing 1000 users, track events in background -async function importUsers(users) { - const imported = await db.users.bulkCreate(users); - - // Queue for background processing - await queue.add('track-events', { - event: 'user_imported', - users: imported - }); -} -``` - -**Add retry logic:** - -```javascript -async function trackEventWithRetry(event, email, data, retries = 3) { - for (let i = 0; i < retries; i++) { - try { - return await trackEvent(event, email, data); - } catch (error) { - if (i === retries - 1) throw error; - await new Promise(r => setTimeout(r, 1000 * (i + 1))); - } - } -} -``` - -## Common issues - -**Event tracked but workflow not triggering** -- Workflow is enabled -- Event name matches exactly (case-sensitive) -- Contact exists in Plunk -- Contact is subscribed - -**CORS errors in browser** -- Use public key (not secret key) -- Plunk API allows CORS from all origins - -**Contact not created** -- Email must be valid -- Contact is created automatically when event is tracked - -## Next steps - -- [Build a workflow](/tutorials/welcome-series-workflow) triggered by events -- [Campaigns vs Workflows](/concepts/campaigns-vs-workflows) decision guide -- [Stripe integration](/integrations/stripe-billing) for billing events diff --git a/apps/wiki/content/docs/tutorials/first-transactional-email.mdx b/apps/wiki/content/docs/tutorials/first-transactional-email.mdx index e9d2a8b..46e0dc4 100644 --- a/apps/wiki/content/docs/tutorials/first-transactional-email.mdx +++ b/apps/wiki/content/docs/tutorials/first-transactional-email.mdx @@ -4,14 +4,12 @@ description: Send a transactional email in 5 minutes icon: Mail --- -## Get your API key +## 1. Get your API key -1. Go to [Settings → General]({{DASHBOARD_URL}}/settings) +1. Go to **Settings → General** 2. Copy your **Secret Key** (starts with `sk_`) -**Important:** Use Secret Key server-side only. Never expose it in client code. - -## Send an email +## 2. Send the email ```bash curl -X POST {{API_URL}}/v1/send \ @@ -19,55 +17,12 @@ curl -X POST {{API_URL}}/v1/send \ -H "Content-Type: application/json" \ -d '{ "to": "user@example.com", - "subject": "Reset your password", - "body": "

Click here to reset: Reset Password

", - "subscribed": true + "subject": "Hello from Plunk", + "body": "

Your first email!

" }' ``` -Replace `sk_your_secret_key` and `user@example.com` with your values. - -### With JavaScript - -```javascript -await fetch('{{API_URL}}/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - to: user.email, - subject: 'Reset your password', - body: `

Click here: Reset Password

`, - subscribed: true - }) -}); -``` - -### With Python - -```python -import requests -import os - -requests.post('{{API_URL}}/v1/send', - headers={ - 'Authorization': f'Bearer {os.environ["PLUNK_SECRET_KEY"]}', - 'Content-Type': 'application/json' - }, - json={ - 'to': user.email, - 'subject': 'Reset your password', - 'body': f'

Click here: Reset Password

', - 'subscribed': True - } -) -``` - -## Use variables - -Make emails dynamic with variables: +## 3. With variables ```javascript await fetch('{{API_URL}}/v1/send', { @@ -81,97 +36,21 @@ await fetch('{{API_URL}}/v1/send', { subject: 'Reset your password', body: '

Hi {{name}}, click here: Reset

', name: user.name, - resetLink: `https://app.com/reset/${token}`, - subscribed: true + resetLink: `https://app.com/reset/${token}` }) }); ``` -Variables in the body (`{{name}}`, `{{resetLink}}`) are replaced with the values you provide. +## 4. Using a template -## Use templates - -Instead of passing HTML every time, create reusable templates. - -### Create a template - -1. Go to **Templates** → **Create Template** -2. Name: `Password Reset` -3. Type: `Transactional` -4. Subject: `Reset your password` -5. Body: -```html -

Hi {{name}},

-

Click here to reset your password:

-

Reset Password

-

This link expires in 1 hour.

-``` - -### Send with template +1. Create template in **Templates → Create Template** +2. Send using template ID: ```javascript -await fetch('{{API_URL}}/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - to: user.email, - template: 'password-reset', // Use template slug - name: user.name, - resetLink: `https://app.com/reset/${token}`, - subscribed: true - }) -}); +{ + to: user.email, + template: 'clx123abc456', + name: user.name, + resetLink: resetUrl +} ``` - -No need to pass `subject` or `body` - they come from the template. - -## Integration example - -```javascript -// Express.js password reset endpoint -app.post('/forgot-password', async (req, res) => { - const { email } = req.body; - - const user = await db.users.findOne({ email }); - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - const token = crypto.randomBytes(32).toString('hex'); - await db.resetTokens.create({ userId: user.id, token, expiresAt: Date.now() + 3600000 }); - - // Send email via Plunk - await fetch('{{API_URL}}/v1/send', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${process.env.PLUNK_SECRET_KEY}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - to: user.email, - subject: 'Reset your password', - body: `

Click here: Reset Password

`, - subscribed: true - }) - }); - - res.json({ success: true }); -}); -``` - -## Troubleshooting - -**"Unauthorized" error** — Check your API key. Must be Secret Key (starts with `sk_`). - -**Email not received** — Check spam folder. If using custom domain, verify it in Settings → Domains. - -**Variables not replaced** — Ensure variable names match exactly (case-sensitive). - -## Next steps - -- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences -- [Set up custom domain](/guides/custom-domains) for better deliverability -- [Track events](/tutorials/event-tracking-integration) to trigger workflows diff --git a/apps/wiki/content/docs/tutorials/index.mdx b/apps/wiki/content/docs/tutorials/index.mdx deleted file mode 100644 index 8db94fc..0000000 --- a/apps/wiki/content/docs/tutorials/index.mdx +++ /dev/null @@ -1,37 +0,0 @@ ---- -title: Tutorials -description: Step-by-step guides -icon: BookOpen ---- - -## Getting started - - - - Send a transactional email via API - - - - Create a 3-email automated workflow - - - - Broadcast to your audience - - - -## Advanced - - - - Filter and target specific audiences - - - - Track user behavior to trigger workflows - - - - Recover abandoned carts automatically - - diff --git a/apps/wiki/content/docs/tutorials/meta.json b/apps/wiki/content/docs/tutorials/meta.json index d3d18ff..4daa083 100644 --- a/apps/wiki/content/docs/tutorials/meta.json +++ b/apps/wiki/content/docs/tutorials/meta.json @@ -1,13 +1,4 @@ { "title": "Tutorials", - "pages": [ - "index", - "first-transactional-email", - "welcome-series-workflow", - "newsletter-campaign", - "segment-based-targeting", - "cart-abandonment-automation", - "user-lifecycle-emails", - "event-tracking-integration" - ] + "pages": ["first-transactional-email", "welcome-series-workflow", "newsletter-campaign", "segment-based-targeting"] } diff --git a/apps/wiki/content/docs/tutorials/newsletter-campaign.mdx b/apps/wiki/content/docs/tutorials/newsletter-campaign.mdx index 9ec29be..9230258 100644 --- a/apps/wiki/content/docs/tutorials/newsletter-campaign.mdx +++ b/apps/wiki/content/docs/tutorials/newsletter-campaign.mdx @@ -1,292 +1,39 @@ --- -title: Send a Newsletter Campaign +title: Send a Newsletter description: Broadcast an email to your audience icon: Send --- -## Overview +## 1. Create a campaign -Campaigns let you send one-time broadcasts to your contacts from the dashboard. No code required. +1. Go to **Campaigns → Create Campaign** +2. Name: `March Newsletter` -## Create a campaign +## 2. Write your email -1. Go to **Campaigns** → **Create Campaign** -2. Fill in basic info: - - Name: `March Product Update` - - Description: `Monthly newsletter for March 2024` +- **From:** Your verified domain +- **Subject:** `March updates you'll love` +- Write your content -## Write your email +## 3. Select audience -### Email settings +Choose one: +- **All contacts:** Everyone subscribed +- **Segment:** A saved segment +- **Filtered:** Custom filters for this campaign -- **From**: Your email or verified domain -- **Subject**: `March product updates you'll love` -- **Preview text**: Shows in inbox preview - -### Email content - -Use the visual editor or write HTML: - -```html -

What's new in March

- -

Hi {{firstName ?? 'there'}},

- -

We've been busy this month. Here's what's new:

- -

🚀 New Feature: Team Collaboration

-

Invite team members and collaborate in real-time.

- -

⚡ Improved Performance

-

Everything is now 2x faster.

- -

📊 New Analytics Dashboard

-

Better insights into your data.

- -

View Full Changelog

- -

Thanks,
The Team

- -

- You're receiving this because you subscribed to updates. - Unsubscribe -

-``` - -### Use variables - -Available variables: -- `{{firstName}}` - Contact's first name -- `{{email}}` - Contact's email -- `{{id}}` - Contact ID -- `{{unsubscribeUrl}}` - Auto-generated unsubscribe link -- Any custom contact data fields - -**Fallback values:** -```html -

Hi {{firstName ?? 'there'}},

- -``` - -## Select your audience - -### All contacts - -Sends to everyone subscribed in your account. - -### Specific segment - -1. Select **Segment** audience type -2. Choose a segment (e.g., "Premium Users") -3. Campaign sends to all contacts in that segment - -### Filtered audience - -Create custom filters for this campaign only: - -**Example filters:** -- `plan` equals `premium` -- `lastLoginAt` within `30` days -- `country` equals `United States` - -Combine with AND/OR logic. - -## Preview and test - -### Send test email +## 4. Test 1. Click **Send Test** -2. Enter your email address -3. Check your inbox +2. Check your inbox +3. Verify links and content -Verify: -- Subject line -- Email content -- Variables are replaced -- Links work -- Unsubscribe link present +## 5. Send -## Send or schedule +- **Send Now:** Starts immediately +- **Schedule:** Pick date and time -### Send now +## 6. Monitor -1. Click **Send Now** -2. Confirm -3. Campaign starts sending immediately - -### Schedule for later - -1. Click **Schedule** -2. Select date and time -3. Confirm - -Campaign will send automatically at scheduled time. - -## Monitor performance - -### Real-time stats - -Go to **Campaigns** → Your campaign to see: - -- **Recipients**: Total contacts targeted -- **Sent**: How many emails sent -- **Delivered**: Successfully delivered -- **Opened**: Unique opens -- **Clicked**: Unique clicks -- **Bounced**: Failed deliveries - -### Open and click rates - -- **Open rate** = Opened / Delivered × 100% -- **Click rate** = Clicked / Delivered × 100% - -**Good benchmarks:** -- Open rate: 15-25% -- Click rate: 2-5% - -### View in activity - -Go to **Activity** to see: -- Individual email opens -- Link clicks -- Delivery timeline - -## Campaign best practices - -**Subject lines:** -- Keep under 50 characters -- Avoid spam words (FREE, $$, URGENT) -- Personalize: `{{name}}, check out our new feature` -- A/B test different subject lines - -**Send timing:** -- Tuesday-Thursday perform best -- 10am-2pm in recipient's timezone -- Avoid Mondays and Fridays -- Test what works for your audience - -**Content:** -- One clear call-to-action -- Mobile-friendly design -- Keep under 500 words -- Use images sparingly (slow loading) -- Always include unsubscribe link - -**Frequency:** -- Weekly: Maximum for engaged audiences -- Monthly: Safe default -- Quarterly: Minimum to stay top-of-mind -- Don't email too often - causes unsubscribes - -## Advanced: Segment-based campaigns - -### Example: Product announcement to paying customers - -1. Create segment "Paying Customers": - - Filter: `plan` is not `free` - - Filter: `subscribed` equals `true` - -2. Create campaign: - - Subject: `New premium features just for you` - - Audience: Segment "Paying Customers" - -3. Send campaign - only goes to paying customers - -### Example: Re-engagement campaign - -1. Create segment "Inactive Users": - - Filter: `lastLoginAt` within `90` days is `false` - - Filter: `subscribed` equals `true` - -2. Create campaign: - - Subject: `We miss you! Here's what's new` - - Content: Highlight recent updates - - Special offer: 20% off upgrade - -3. Send or schedule - -## Campaign vs workflow - -**Use Campaign when:** -- One-time send (newsletter, announcement) -- Manual timing -- Same message to everyone -- No automation needed - -**Use Workflow when:** -- Multi-email sequence needed -- Trigger on user action -- Delays between emails -- Personalized paths (if/then logic) - -See [Campaigns vs Workflows](/concepts/campaigns-vs-workflows) for full comparison. - -## Duplicate and reuse - -### Duplicate a campaign - -1. Go to campaign -2. Click **Duplicate** -3. Edit content -4. Send to same or different audience - -Useful for monthly newsletters - duplicate last month's, update content. - -### Save as template - -If you'll reuse the design: - -1. **Templates** → **Create Template** -2. Paste your campaign HTML -3. Save - -Now you can create campaigns faster using the template. - -## Cancel a campaign - -### Before sending - -1. Go to campaign (status: Draft or Scheduled) -2. Click **Delete** - -### While sending - -1. Go to campaign (status: Sending) -2. Click **Cancel** -3. Stops queuing new emails (already sent emails can't be recalled) - -### After sending - -Cannot cancel or recall. Sent emails are delivered. - -## Troubleshooting - -**Campaign not sending** -- Check campaign status (Draft needs to be sent) -- Verify audience has contacts -- Ensure contacts are subscribed -- Custom domain must be verified - -**Low open rate** -- Improve subject line -- Check spam folder placement -- Verify sender email/domain -- Review send time - -**High unsubscribe rate** -- Sending too frequently -- Content not relevant -- Set better expectations at signup -- Review targeting - -**Emails going to spam** -- Verify custom domain -- Avoid spam trigger words -- Don't use all caps or excessive punctuation -- Warm up new sending domain - -## Next steps - -- [Build a workflow](/tutorials/welcome-series-workflow) for automated sequences -- [Create segments](/tutorials/segment-based-targeting) for better targeting -- [Set up custom domain](/guides/custom-domains) for better deliverability +View stats at **Campaigns → Your campaign**: +- Sent, delivered, opened, clicked, bounced diff --git a/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx b/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx index 5afdd5c..6f793a4 100644 --- a/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx +++ b/apps/wiki/content/docs/tutorials/segment-based-targeting.mdx @@ -1,298 +1,33 @@ --- -title: Segment-Based Targeting -description: Target specific audiences with filters +title: Target with Segments +description: Send to specific audiences icon: Users --- -## Overview +## 1. Create a segment -Segments are dynamic groups of contacts based on filters. Use them to send targeted campaigns or trigger workflows when contacts enter/exit segments. - -## Create a segment - -1. Go to **Segments** → **Create Segment** +1. Go to **Segments → Create Segment** 2. Name: `Premium Users` -3. Description: `Users on premium or enterprise plan` -## Add filters +## 2. Add filters -### Simple filter - -Filter by a single field: +Example: Premium subscribers - Field: `plan` - Operator: `equals` - Value: `premium` -All contacts where `plan` equals `premium` are in this segment. +Add more conditions with AND/OR. -### Multiple filters (AND logic) - -All conditions must be true: - -- `plan` equals `premium` -- **AND** `subscribed` equals `true` -- **AND** `lastLoginAt` within `30` days - -Only premium users who are subscribed AND logged in recently. - -### Multiple filters (OR logic) - -Any condition can be true: - -- `plan` equals `premium` -- **OR** `plan` equals `enterprise` - -Users on either premium or enterprise plan. - -### Complex filters (AND + OR) - -Combine both: - -- `subscribed` equals `true` -- **AND** (`plan` equals `premium` **OR** `plan` equals `enterprise`) - -Subscribed users on premium OR enterprise plans. - -## Filter operators - -### Equals - -Exact match: -- `plan` equals `premium` -- `country` equals `United States` - -### Not equals - -Everything except: -- `plan` not equals `free` -- `status` not equals `cancelled` - -### Contains - -Partial text match: -- `email` contains `@gmail.com` -- `companyName` contains `Inc` - -### Greater than / Less than - -Numeric comparisons: -- `mrr` greater than `100` -- `age` less than `30` -- `loginCount` greater than `10` - -### Exists / Does not exist - -Field has any value: -- `phoneNumber` exists -- `referralCode` does not exist - -### Within - -Time-based (requires ISO date): -- `signupDate` within `7` days -- `lastLoginAt` within `30` days -- `trialExpiresAt` within `3` days - -## Example segments - -### Active users - -Users who logged in recently: - -- `lastLoginAt` within `7` days -- **AND** `subscribed` equals `true` - -### High-value customers - -Users spending over $100/month: - -- `mrr` greater than `100` -- **AND** `plan` is not `free` - -### Trial expiring soon - -Users whose trial ends in 3 days: - -- `trialExpiresAt` within `3` days -- **AND** `plan` equals `trial` - -### Inactive users - -Haven't logged in for 30+ days: - -- `lastLoginAt` within `30` days is `false` -- **AND** `subscribed` equals `true` -- **AND** `status` equals `active` - -**Note:** To check "NOT within", set the within filter and toggle the NOT operator. - -### Power users - -High engagement score: - -- `loginCount` greater than `50` -- **AND** `featureUsageCount` greater than `100` - -### Geographic targeting - -Specific country or region: - -- `country` equals `United States` -- **AND** `state` equals `California` - -### Feature adopters - -Used a specific feature: - -- Custom event filter: `feature_used` triggered -- **AND** event data: `featureName` equals `advanced_analytics` - -## Use segments in campaigns - -### Target a segment +## 3. Use in campaigns 1. Create campaign 2. Audience: Select **Segment** +3. Choose `Premium Users` + +## 4. Use in workflows + +1. Create workflow +2. Trigger: **Segment entry** 3. Choose your segment -4. Campaign sends only to contacts in that segment - -### Preview count - -Before sending, see how many contacts match: -- Shows estimated recipient count -- Updates in real-time as you adjust filters - -## Use segments in workflows - -### Trigger on segment entry - -Create workflow that runs when contacts enter a segment: - -1. **Workflows** → **Create Workflow** -2. Trigger: Segment `trial_expiring_soon` -3. Trigger condition: Contact **enters** segment -4. Add email: Trial expiration reminder - -When a contact enters "trial_expiring_soon" segment, workflow triggers. - -### Trigger on segment exit - -Run workflow when contacts leave a segment: - -1. Trigger: Segment `active_users` -2. Trigger condition: Contact **exits** segment -3. Add workflow: Re-engagement sequence - -When user becomes inactive (exits "active_users"), re-engagement starts. - -## Track membership changes - -Enable to trigger events when contacts enter/exit: - -1. Edit segment -2. Toggle **Track Membership** -3. Save - -Now when contacts move in/out of the segment: -- Event `segment_entry_[segment_id]` is tracked -- Event `segment_exit_[segment_id]` is tracked -- Can use these events in other workflows - -**Performance note:** Only enable for segments you'll use for triggers. Adds processing overhead. - -## Segment best practices - -**Keep it simple** -- 3-5 filters per segment max -- Avoid deeply nested conditions -- Test with expected contacts - -**Use consistent data** -- Store dates as ISO strings -- Use numbers for numeric values -- Consistent field naming - -**Name clearly** -- ✅ `Premium Users - Active` -- ❌ `Segment 1` - -**Monitor size** -- Check segment count regularly -- Too large = slow processing -- Too small = not enough data - -## Update segments - -Segments update automatically: -- When contact data changes -- When contacts are added/removed -- Typically updates within minutes - -Force refresh: -1. Go to segment -2. Click **Refresh Count** - -## Advanced: Multi-level targeting - -### Premium users in specific region - -- `plan` equals `premium` -- **AND** `country` equals `United States` -- **AND** `lastLoginAt` within `7` days - -### Churn risk scoring - -- `lastLoginAt` within `30` days is `false` -- **AND** `supportTickets` greater than `3` -- **AND** `npsScore` less than `7` - -### Upsell targeting - -- `plan` equals `free` -- **AND** `featureUsageCount` greater than `50` -- **AND** `teamSize` greater than `5` - -Users on free plan who are power users with teams (good upsell candidates). - -## Performance at scale - -Segments work efficiently with millions of contacts: - -- Indexed queries for fast filtering -- Cursor-based pagination -- Background count computation - -**Tips for large segments:** -- Use specific filters (avoid `contains` on large text fields) -- Store commonly queried data as top-level fields -- Use numeric comparisons when possible (faster than text) - -## Troubleshooting - -**Segment count is 0 but should have contacts** -- Check filter logic (AND vs OR) -- Verify field names match exactly (case-sensitive) -- Ensure contacts have the required fields -- Try simpler filters to debug - -**Segment not updating** -- Click **Refresh Count** to force update -- Check that contact data was actually updated -- Segments typically update within 5 minutes - -**Workflow not triggering on segment entry** -- Workflow is enabled -- Track Membership is enabled on segment -- Trigger is set to segment entry (not exit) - -**Too many contacts in segment** -- Filters too broad -- Add more specific conditions -- Use AND logic instead of OR - -## Next steps - -- [Send a campaign](/tutorials/newsletter-campaign) to a segment -- [Build a workflow](/tutorials/welcome-series-workflow) triggered by segment changes -- [Track events](/tutorials/event-tracking-integration) to update contact data +4. Enable **Track membership changes** on the segment diff --git a/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx b/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx index b550070..8c63103 100644 --- a/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx +++ b/apps/wiki/content/docs/tutorials/welcome-series-workflow.mdx @@ -6,31 +6,26 @@ icon: Workflow ## What you'll build -An automated workflow that sends 3 emails when users sign up: - Day 0: Welcome email (immediate) -- Day 1: Feature tour (24 hours later) -- Day 3: Help offer (48 hours after that) +- Day 1: Feature tour +- Day 3: Help offer -## Create the workflow +## 1. Create templates -1. **Workflows** → **Create Workflow** +Create 3 templates in **Templates → Create Template**: +- `Welcome` (use `{{name}}` for personalization) +- `Feature Tour` +- `Help Offer` + +## 2. Create the workflow + +1. Go to **Workflows → Create Workflow** 2. Name: `Welcome Series` -3. Trigger Event: `user_signed_up` -4. Allow Re-entry: `No` (users get this once) -5. Click **Create** +3. Trigger: Event `user_signed_up` +4. Allow Re-entry: No -## Build the flow +## 3. Build the flow -In the visual editor: - -1. **Add Send Email** step → Select your welcome template -2. **Add Delay** step → 1 day -3. **Add Send Email** step → Select your feature tour template -4. **Add Delay** step → 2 days -5. **Add Send Email** step → Select your help offer template -6. **Add Exit** step - -Your flow: ``` [Trigger: user_signed_up] ↓ @@ -47,110 +42,27 @@ Your flow: [Exit] ``` -7. **Enable** the workflow (toggle switch) +## 4. Enable the workflow -## Track the signup event +Toggle the workflow ON. -Add event tracking to your app when users sign up. +## 5. Track signups -### JavaScript +Add to your app: ```javascript -// After successful signup await fetch('{{API_URL}}/v1/track', { method: 'POST', headers: { - 'Authorization': `Bearer ${process.env.NEXT_PUBLIC_PLUNK_PUBLIC_KEY}`, + 'Authorization': `Bearer ${PLUNK_PUBLIC_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ event: 'user_signed_up', email: user.email, - data: { - name: user.name, - dashboardUrl: 'https://app.yourapp.com/dashboard', - docsUrl: 'https://docs.yourapp.com' - } + data: { name: user.name } }) }); ``` -### Python - -```python -import requests - -requests.post('{{API_URL}}/v1/track', - headers={ - 'Authorization': f'Bearer {os.environ["PLUNK_PUBLIC_KEY"]}', - 'Content-Type': 'application/json' - }, - json={ - 'event': 'user_signed_up', - 'email': user.email, - 'data': { - 'name': user.name, - 'dashboardUrl': 'https://app.yourapp.com/dashboard', - 'docsUrl': 'https://docs.yourapp.com' - } - } -) -``` - -**Important:** Use your **Public Key** (starts with `pk_`) for event tracking. - -## Test the workflow - -### Manual test - -1. Go to **Workflows** → Your workflow → **Executions** tab -2. Click **Create Execution** -3. Select a test contact -4. Add test data: -```json -{ - "name": "Test User", - "dashboardUrl": "https://app.yourapp.com", - "docsUrl": "https://docs.yourapp.com" -} -``` -5. **Start Execution** - -Check your email - you should receive the welcome email immediately. The workflow will pause at the delay steps. - -### Faster testing - -For testing, temporarily change delays to 5 minutes instead of days. Test the flow, then change back. - -## Monitor performance - -1. **Workflows** → Your workflow -2. Check **Executions** to see who's in the workflow -3. Go to **Activity** to see email opens/clicks -4. Track open rates for each email - -Typical good rates: -- Email 1: 60-80% open rate -- Email 2: 40-60% open rate -- Email 3: 30-50% open rate - -## Troubleshooting - -**Workflow not triggering** — Check: -- Workflow is enabled (toggle ON) -- Event name matches exactly: `user_signed_up` -- Using Public Key for tracking -- Contact exists and is subscribed - -**Email not sending** — Check: -- Template exists -- Contact is subscribed -- Variables in event data match template variables - -**Duplicate emails** — Ensure `Allow Re-entry` is `No`. - -## Next steps - -- [Add conditional logic](/automation-patterns/conditional-branching) for different user types -- [Track more events](/tutorials/event-tracking-integration) to trigger workflows -- [Build cart abandonment](/tutorials/cart-abandonment-automation) workflow +Use your **Public Key** (starts with `pk_`).