diff --git a/apps/api/package.json b/apps/api/package.json index 00566a6..5d9fc12 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,7 +30,6 @@ "cors": "^2.8.5", "csv-parse": "^6.1.0", "dayjs": "^1.11.19", - "disposable-email": "^0.2.3", "dotenv": "^17.2.3", "express": "^5.1.0", "helmet": "^8.1.0", @@ -46,7 +45,6 @@ "@types/bcrypt": "^6.0.0", "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.17", - "@types/disposable-email": "^0", "@types/express": "^5.0.5", "@types/helmet": "^4.0.0", "@types/jsonwebtoken": "^9.0.6", diff --git a/apps/api/src/services/EmailVerificationService.ts b/apps/api/src/services/EmailVerificationService.ts index 9139fa0..71f8547 100644 --- a/apps/api/src/services/EmailVerificationService.ts +++ b/apps/api/src/services/EmailVerificationService.ts @@ -1,6 +1,6 @@ import {promises as dns} from 'dns'; import {run} from '@zootools/email-spell-checker'; -import disposable from 'disposable-email'; +import {redis} from '../database/redis.js'; export interface EmailVerificationResult { email: string; @@ -14,7 +14,67 @@ export interface EmailVerificationResult { reasons: string[]; } +const DISPOSABLE_DOMAINS_URL = + 'https://raw.githubusercontent.com/disposable-email-domains/disposable-email-domains/main/disposable_email_blocklist.conf'; +const DISPOSABLE_DOMAINS_CACHE_KEY = 'email:disposable_domains'; +const CACHE_TTL_SECONDS = 24 * 60 * 60; // 24 hours (list updates daily) + export class EmailVerificationService { + private static disposableDomainsSet: Set | null = null; + + /** + * Fetch and cache the disposable domains list from GitHub + * Uses Redis for caching with 24-hour TTL + * Falls back to in-memory cache if Redis fails + */ + private static async getDisposableDomains(): Promise> { + // Return in-memory cache if available + if (this.disposableDomainsSet) { + return this.disposableDomainsSet; + } + + try { + // Try to get from Redis cache first + const cached = await redis.get(DISPOSABLE_DOMAINS_CACHE_KEY); + if (cached) { + const domains = JSON.parse(cached) as string[]; + this.disposableDomainsSet = new Set(domains); + return this.disposableDomainsSet; + } + + // Fetch from GitHub if not in cache + const response = await fetch(DISPOSABLE_DOMAINS_URL); + if (!response.ok) { + throw new Error(`Failed to fetch disposable domains: ${response.statusText}`); + } + + const text = await response.text(); + const domains = text + .split('\n') + .map(line => line.trim()) + .filter(line => line && !line.startsWith('#')); // Filter empty lines and comments + + // Cache in Redis + await redis.set(DISPOSABLE_DOMAINS_CACHE_KEY, JSON.stringify(domains), 'EX', CACHE_TTL_SECONDS); + + // Cache in memory + this.disposableDomainsSet = new Set(domains); + return this.disposableDomainsSet; + } catch (error) { + console.error('Error fetching disposable domains:', error); + // Return empty set as fallback - don't block email verification + return new Set(); + } + } + + /** + * Check if a domain is disposable + */ + private static async isDisposableDomain(domain: string): Promise { + const disposableDomains = await this.getDisposableDomains(); + return disposableDomains.has(domain.toLowerCase()); + } + /** * Verify an email address * - Checks if domain exists (DNS A/AAAA records) @@ -44,9 +104,8 @@ export class EmailVerificationService { const domain = emailParts[1]!; // Safe to assert, we already validated length - // Check if email is from a disposable domain - // MailChecker.isValid returns false for disposable emails - result.isDisposable = disposable.validate(domain); + // Check if email is from a disposable domain using GitHub list + result.isDisposable = await this.isDisposableDomain(domain); // Check for plus addressing result.isPlusAddressed = emailParts[0]!.includes('+'); diff --git a/apps/landing/package.json b/apps/landing/package.json index c3d1dbf..164c915 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -14,6 +14,18 @@ "@plunk/db": "*", "@plunk/shared": "*", "@plunk/ui": "*", + "@tiptap/core": "^3.11.0", + "@tiptap/extension-color": "^3.11.0", + "@tiptap/extension-image": "^3.11.0", + "@tiptap/extension-link": "^3.11.0", + "@tiptap/extension-placeholder": "^3.11.0", + "@tiptap/extension-text-align": "^3.11.0", + "@tiptap/extension-text-style": "^3.11.0", + "@tiptap/extension-underline": "^3.11.0", + "@tiptap/pm": "^3.11.0", + "@tiptap/react": "^3.11.0", + "@tiptap/starter-kit": "^3.11.0", + "juice": "^11.0.3", "lucide-react": "^0.553.0", "next": "^16.0.10", "next-seo": "^6.6.0", diff --git a/apps/landing/src/components/tools/EmailVerificationResult.tsx b/apps/landing/src/components/tools/EmailVerificationResult.tsx new file mode 100644 index 0000000..137fcfd --- /dev/null +++ b/apps/landing/src/components/tools/EmailVerificationResult.tsx @@ -0,0 +1,150 @@ +import {AlertCircle, AlertTriangle, CheckCircle, Info, Mail, Server, Shield, Trash2, XCircle} from 'lucide-react'; +import type {EmailVerificationResult as VerificationResult} from '../../lib/emailVerification'; + +interface EmailVerificationResultProps { + result: VerificationResult; +} + +export function EmailVerificationResult({result}: EmailVerificationResultProps) { + return ( +
+ {/* Overall Status */} +
+
+ {result.valid ? ( + + ) : ( + + )} +
+

+ {result.valid ? 'Valid Email' : 'Invalid Email'} +

+

{result.email}

+
+
+
+ + {/* Detailed Checks */} +
+
+

Verification Details

+
+
+ {/* Domain Exists */} +
+
+ +
+

Domain Exists

+

DNS A/AAAA records found

+
+
+ {result.domainExists ? ( + + ) : ( + + )} +
+ + {/* MX Records */} +
+
+ +
+

MX Records

+

Mail server configured

+
+
+ {result.hasMxRecords ? ( + + ) : ( + + )} +
+ + {/* Disposable Email */} +
+
+ +
+

Disposable Email

+

Temporary email service

+
+
+ {result.isDisposable ? ( + + ) : ( + + )} +
+ + {/* Typo Detection */} +
+
+ +
+

Typo Check

+

Common spelling errors

+
+
+ {result.isTypo ? ( + + ) : ( + + )} +
+ + {/* Plus Addressing */} +
+
+ +
+

Plus Addressing

+

Uses + tag (user+tag@domain.com)

+
+
+ {result.isPlusAddressed ? ( + + ) : ( + No + )} +
+
+
+ + {/* Suggested Email (if typo detected) */} + {result.suggestedEmail && ( +
+
+ +
+

Did you mean?

+

+ {result.suggestedEmail} +

+
+
+
+ )} + + {/* Reasons */} + {result.reasons && result.reasons.length > 0 && ( +
+

Analysis

+
    + {result.reasons.map((reason, index) => ( +
  • + {reason} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/landing/src/components/tools/MarkdownEmailEditor.tsx b/apps/landing/src/components/tools/MarkdownEmailEditor.tsx new file mode 100644 index 0000000..4d53979 --- /dev/null +++ b/apps/landing/src/components/tools/MarkdownEmailEditor.tsx @@ -0,0 +1,77 @@ +import {EditorContent, useEditor} from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; +import {TextAlign} from '@tiptap/extension-text-align'; +import {Color} from '@tiptap/extension-color'; +import {TextStyle} from '@tiptap/extension-text-style'; +import {Link} from '@tiptap/extension-link'; +import {Underline} from '@tiptap/extension-underline'; +import {Image} from '@tiptap/extension-image'; +import Placeholder from '@tiptap/extension-placeholder'; +import {MarkdownEmailToolbar} from './MarkdownEmailToolbar'; +import {useEffect} from 'react'; + +interface MarkdownEmailEditorProps { + value: string; + onChange: (value: string) => void; +} + +export function MarkdownEmailEditor({value, onChange}: MarkdownEmailEditorProps) { + const editor = useEditor({ + immediatelyRender: false, + extensions: [ + StarterKit.configure({ + heading: { + levels: [1, 2, 3], + }, + }), + TextAlign.configure({ + types: ['heading', 'paragraph'], + alignments: ['left', 'center', 'right', 'justify'], + }), + Color, + TextStyle, + Underline, + Link.configure({ + openOnClick: false, + HTMLAttributes: { + rel: 'noopener noreferrer', + }, + }), + Image.configure({ + HTMLAttributes: { + class: 'email-image', + }, + inline: false, + }), + Placeholder.configure({ + placeholder: 'Start typing your email here...', + }), + ], + content: value || '', + editorProps: { + attributes: { + class: 'prose prose-sm max-w-none focus:outline-none min-h-[500px] px-4 py-3 text-neutral-900', + }, + }, + onUpdate: ({editor}) => { + const html = editor.getHTML(); + onChange(html); + }, + }); + + // Update editor content when value prop changes from outside + useEffect(() => { + if (editor && value !== editor.getHTML()) { + editor.commands.setContent(value || ''); + } + }, [value, editor]); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/apps/landing/src/components/tools/MarkdownEmailToolbar.tsx b/apps/landing/src/components/tools/MarkdownEmailToolbar.tsx new file mode 100644 index 0000000..dbbf4ff --- /dev/null +++ b/apps/landing/src/components/tools/MarkdownEmailToolbar.tsx @@ -0,0 +1,492 @@ +import {type Editor} from '@tiptap/react'; +import { + AlignCenter, + AlignJustify, + AlignLeft, + AlignRight, + Bold, + Code, + Heading1, + Heading2, + Heading3, + Image as ImageIcon, + Italic, + Link, + List, + ListOrdered, + Palette, + Quote, + Redo, + Strikethrough, + Underline as UnderlineIcon, + Undo, +} from 'lucide-react'; +import {Button, Input} from '@plunk/ui'; +import {useCallback, useState} from 'react'; +import {EDITOR_COLOR_GROUPS} from '../../lib/editorColors'; + +interface ToolbarProps { + editor: Editor | null; +} + +export function MarkdownEmailToolbar({editor}: ToolbarProps) { + const [showLinkInput, setShowLinkInput] = useState(false); + const [linkUrl, setLinkUrl] = useState(''); + const [showColorPicker, setShowColorPicker] = useState(false); + const [selectedColor, setSelectedColor] = useState('#000000'); + const [customColor, setCustomColor] = useState(''); + const [showImageInput, setShowImageInput] = useState(false); + const [imageUrl, setImageUrl] = useState(''); + + // Factory function to create editor command handlers + const createCommandHandler = useCallback( + (command: (editor: Editor) => void) => () => { + if (!editor) return; + command(editor); + }, + [editor], + ); + + // Complex handlers that need state management + const addLink = useCallback(() => { + if (!editor || !linkUrl) return; + if (editor.isActive('link')) { + editor.chain().focus().extendMarkRange('link').setLink({href: linkUrl}).run(); + } else { + editor.chain().focus().setLink({href: linkUrl}).run(); + } + setLinkUrl(''); + setShowLinkInput(false); + }, [editor, linkUrl]); + + const removeLink = useCallback(() => { + if (!editor) return; + editor.chain().focus().unsetLink().run(); + setLinkUrl(''); + setShowLinkInput(false); + }, [editor]); + + const setColor = useCallback( + (color: string) => { + if (!editor) return; + editor.chain().focus().setColor(color).run(); + setSelectedColor(color); + }, + [editor], + ); + + const applyCustomColor = useCallback(() => { + if (customColor && /^#[0-9A-F]{6}$/i.test(customColor)) { + setColor(customColor); + setCustomColor(''); + setShowColorPicker(false); + } + }, [customColor, setColor]); + + const toggleLinkInput = useCallback(() => { + if (!editor) return; + if (editor.isActive('link')) { + const previousUrl = editor.getAttributes('link').href || ''; + setLinkUrl(previousUrl); + setShowLinkInput(true); + } else { + setShowLinkInput(!showLinkInput); + setLinkUrl(''); + } + }, [editor, showLinkInput]); + + const addImage = useCallback(() => { + if (!editor || !imageUrl) return; + editor.chain().focus().setImage({src: imageUrl}).run(); + setImageUrl(''); + setShowImageInput(false); + }, [editor, imageUrl]); + + if (!editor) { + return null; + } + + return ( +
+ {/* History */} +
+ + +
+ + {/* Text formatting */} +
+ + + + + +
+ + {/* Headings */} +
+ + + +
+ + {/* Lists */} +
+ + + +
+ + {/* Alignment */} +
+ + + + +
+ + {/* Color picker */} +
+ + {showColorPicker && ( +
+ {/* Custom color input */} +
+ +
+ setCustomColor(e.target.value.toUpperCase())} + placeholder="#000000" + className="h-8 text-xs font-mono" + maxLength={7} + onKeyDown={e => { + if (e.key === 'Enter') { + applyCustomColor(); + } + }} + /> + +
+
+ + {/* Color palette */} +
+ {EDITOR_COLOR_GROUPS.map(group => ( +
+ +
+ {group.colors.map(color => ( + + ))} +
+
+ ))} +
+
+ )} +
+ + {/* Link */} +
+ + {showLinkInput && ( +
+
+ setLinkUrl(e.target.value)} + placeholder="https://example.com" + className="px-2 py-1 text-sm border border-neutral-200 rounded w-64" + onKeyDown={e => { + if (e.key === 'Enter') { + addLink(); + } else if (e.key === 'Escape') { + setShowLinkInput(false); + setLinkUrl(''); + } + }} + autoFocus + /> + +
+ {editor.isActive('link') && ( +
+ +
+ )} +
+ )} +
+ + {/* Image */} +
+ + {showImageInput && ( +
+
+ setImageUrl(e.target.value)} + placeholder="https://example.com/image.jpg" + className="px-2 py-1 text-sm border border-neutral-200 rounded w-64" + onKeyDown={e => { + if (e.key === 'Enter') { + addImage(); + } else if (e.key === 'Escape') { + setShowImageInput(false); + setImageUrl(''); + } + }} + autoFocus + /> + +
+
+ )} +
+
+ ); +} diff --git a/apps/landing/src/lib/editorColors.ts b/apps/landing/src/lib/editorColors.ts new file mode 100644 index 0000000..1cda82f --- /dev/null +++ b/apps/landing/src/lib/editorColors.ts @@ -0,0 +1,38 @@ +/** + * Color palette for the email editor toolbar + * Organized by hue using Tailwind color values + */ +export const EDITOR_COLOR_GROUPS = [ + { + name: 'Neutrals', + colors: ['#000000', '#374151', '#6B7280', '#9CA3AF', '#D1D5DB', '#F3F4F6', '#FFFFFF'], + }, + { + name: 'Reds', + colors: ['#7F1D1D', '#991B1B', '#DC2626', '#EF4444', '#F87171', '#FCA5A5', '#FEE2E2'], + }, + { + name: 'Oranges', + colors: ['#7C2D12', '#C2410C', '#EA580C', '#F97316', '#FB923C', '#FDBA74', '#FED7AA'], + }, + { + name: 'Yellows', + colors: ['#713F12', '#A16207', '#CA8A04', '#EAB308', '#FACC15', '#FDE047', '#FEF08A'], + }, + { + name: 'Greens', + colors: ['#14532D', '#15803D', '#16A34A', '#22C55E', '#4ADE80', '#86EFAC', '#BBF7D0'], + }, + { + name: 'Blues', + colors: ['#1E3A8A', '#1D4ED8', '#2563EB', '#3B82F6', '#60A5FA', '#93C5FD', '#DBEAFE'], + }, + { + name: 'Purples', + colors: ['#581C87', '#6B21A8', '#7C3AED', '#8B5CF6', '#A78BFA', '#C4B5FD', '#E9D5FF'], + }, + { + name: 'Pinks', + colors: ['#831843', '#9F1239', '#DB2777', '#EC4899', '#F472B6', '#F9A8D4', '#FBCFE8'], + }, +] as const; diff --git a/apps/landing/src/lib/emailHtmlConverter.ts b/apps/landing/src/lib/emailHtmlConverter.ts new file mode 100644 index 0000000..9961f43 --- /dev/null +++ b/apps/landing/src/lib/emailHtmlConverter.ts @@ -0,0 +1,170 @@ +import juice from 'juice'; + +/** + * Converts modern HTML from Tiptap to email-friendly HTML + * - Inlines CSS styles + * - Adds email-safe defaults + * - Preserves variable placeholders like {{email}} + */ +export function convertToEmailHtml(html: string): string { + // Wrap in email-safe container with basic styling + const wrappedHtml = ` + + + + + + ${html} + + + `; + + // Inline CSS using juice + const inlined = juice(wrappedHtml, { + preserveMediaQueries: false, + preserveFontFaces: false, + removeStyleTags: true, + applyStyleTags: true, + }); + + // Extract just the body content + const bodyMatch = inlined.match(/]*>([\s\S]*)<\/body>/i); + const bodyContent = bodyMatch && bodyMatch[1] ? bodyMatch[1].trim() : inlined; + + // Clean up Tiptap-specific artifacts + const cleaned = bodyContent + .replace(/\sdata-pm-slice="[^"]*"/g, '') + .replace(/\sclass=""/g, '') + .replace(/\sstyle=""/g, ''); + + return cleaned; +} + +/** + * Wraps email fragment HTML in a complete HTML document structure + * This is what people expect when they want to send an email + */ +export function wrapEmailHtml(bodyContent: string): string { + return ` + + + + + Email + + +${bodyContent} + +`; +} + +/** + * Converts modern HTML from Tiptap to a complete, ready-to-send email HTML + * - Inlines CSS styles + * - Adds email-safe defaults + * - Wraps in complete HTML document structure + * - Preserves variable placeholders like {{email}} + */ +export function convertToCompleteEmailHtml(html: string): string { + const fragment = convertToEmailHtml(html); + return wrapEmailHtml(fragment); +} + diff --git a/apps/landing/src/lib/emailVerification.ts b/apps/landing/src/lib/emailVerification.ts new file mode 100644 index 0000000..491519f --- /dev/null +++ b/apps/landing/src/lib/emailVerification.ts @@ -0,0 +1,26 @@ +export interface EmailVerificationResult { + email: string; + valid: boolean; + isDisposable: boolean; + isTypo: boolean; + isPlusAddressed: boolean; + domainExists: boolean; + hasMxRecords: boolean; + suggestedEmail?: string; + reasons: string[]; +} + +export async function verifyEmail(email: string): Promise { + const response = await fetch('/api/verify-email', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({email}), + }); + + if (!response.ok) { + const error = await response.json(); + throw new Error(error.error || 'Verification failed'); + } + + return response.json(); +} diff --git a/apps/landing/src/lib/toolsContent.ts b/apps/landing/src/lib/toolsContent.ts new file mode 100644 index 0000000..6ffc3e0 --- /dev/null +++ b/apps/landing/src/lib/toolsContent.ts @@ -0,0 +1,37 @@ +import {CheckCircle, Mail, Search, Shield} from 'lucide-react'; + +/** + * Educational content for the email verification tool + */ +export const EMAIL_VERIFICATION_FEATURES = [ + { + title: 'Improve Deliverability', + description: 'Remove invalid emails before sending to reduce bounce rates and improve email deliverability.', + icon: Mail, + }, + { + title: 'Catch Typos', + description: 'Detect common typos like "gmial.com" and suggest corrections to capture valid addresses.', + icon: Search, + }, + { + title: 'Protect Reputation', + description: 'High bounce rates hurt your sender reputation. Verify emails to maintain a good standing.', + icon: Shield, + }, + { + title: 'DNS Validation', + description: 'Check if the email domain exists and has properly configured MX records for receiving mail.', + icon: CheckCircle, + }, + { + title: 'Disposable Detection', + description: 'Identify temporary email addresses that are often used for spam or fake signups.', + icon: Mail, + }, + { + title: 'Plus Addressing', + description: 'Detect plus-addressed emails (user+tag@domain.com) which can be useful for tracking.', + icon: Shield, + }, +] as const; diff --git a/apps/landing/src/pages/api/verify-email.ts b/apps/landing/src/pages/api/verify-email.ts new file mode 100644 index 0000000..989a6fd --- /dev/null +++ b/apps/landing/src/pages/api/verify-email.ts @@ -0,0 +1,70 @@ +import type {NextApiRequest, NextApiResponse} from 'next'; +import {UtilitySchemas} from '@plunk/shared'; +import {API_URI} from '../../lib/constants'; + +interface VerifyEmailResponse { + email: string; + valid: boolean; + isDisposable: boolean; + isTypo: boolean; + isPlusAddressed: boolean; + domainExists: boolean; + hasMxRecords: boolean; + suggestedEmail?: string; + reasons: string[]; +} + +interface ErrorResponse { + error: string; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse, +) { + // Only allow POST requests + if (req.method !== 'POST') { + return res.status(405).json({error: 'Method not allowed'}); + } + + // Validate input using Zod schema + const result = UtilitySchemas.email.safeParse(req.body); + + if (!result.success) { + return res.status(400).json({error: 'Invalid email format'}); + } + + const {email} = result.data; + + // Get secret key from environment + const secretKey = process.env.PLUNK_SECRET_KEY; + if (!secretKey) { + console.error('PLUNK_SECRET_KEY is not configured'); + return res.status(500).json({error: 'Service configuration error'}); + } + + try { + // Call internal Plunk API with secret key from env + const response = await fetch(`${API_URI}/v1/verify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${secretKey}`, + }, + body: JSON.stringify({email}), + }); + + const data = await response.json(); + + if (!response.ok) { + console.error('Plunk API error:', data); + return res.status(response.status).json({error: data.error?.message || 'Verification failed'}); + } + + // Return the data field from the Plunk API response + return res.status(200).json(data.data); + } catch (error) { + console.error('Error verifying email:', error); + return res.status(500).json({error: 'Internal server error'}); + } +} diff --git a/apps/landing/src/pages/tools/index.tsx b/apps/landing/src/pages/tools/index.tsx new file mode 100644 index 0000000..697048d --- /dev/null +++ b/apps/landing/src/pages/tools/index.tsx @@ -0,0 +1,270 @@ +import {Footer, Navbar} from '../../components'; +import {motion} from 'framer-motion'; +import {DASHBOARD_URI} from '../../lib/constants'; +import React from 'react'; +import Link from 'next/link'; +import {NextSeo} from 'next-seo'; +import {ArrowRight, Code2, Mail, Search, Sparkles, Wrench} from 'lucide-react'; + +const tools = [ + { + name: 'Markdown to Email', + slug: 'markdown-to-email', + description: 'Convert rich text to email-safe HTML with our visual editor and instant preview.', + features: ['Visual Editor', 'Email-Safe HTML', 'Inline CSS', 'Copy to Clipboard'], + icon: Code2, + }, + { + name: 'Email Verification', + slug: 'verify-email', + description: 'Verify email addresses instantly. Check DNS, MX records, typos, and disposable domains.', + features: ['DNS Validation', 'Typo Detection', 'MX Records', 'Disposable Check'], + icon: Search, + }, +]; + +/** + * Free Email Tools index page + */ +export default function ToolsIndex() { + return ( + <> + + + + +
+ {/* Hero Section */} +
+
+ + +
+ + Free Email Tools +
+ +

+ Free tools for +
+ email developers +

+ +

+ Build better emails with our free tools. Convert markdown to email-safe HTML, verify email addresses, and + more. No sign-up required. +

+ +
+ + + Try Plunk free + + + + + Browse guides + +
+
+
+ + {/* Tools Grid */} +
+ +

Available Tools

+

Everything you need to work with emails

+
+ +
+ {tools.map((tool, index) => { + const Icon = tool.icon; + return ( + + +
+
+ +
+ +
+

{tool.name}

+

{tool.description}

+
+ {tool.features.map(feature => ( +
+
+ {feature} +
+ ))} +
+ + + ); + })} +
+
+ + {/* Why Use These Tools */} +
+ +

Why use these tools?

+

Built by email experts for email developers

+
+ +
+ + +

Free Forever

+

+ No sign-up, no paywalls, no limits. Use our tools as much as you need, completely free. We believe in + giving back to the email development community. +

+
+ + + +

Developer-Focused

+

+ Built by developers who work with email every day. Clean outputs, instant results, and designed for + real-world email workflows. +

+
+ + + +

Production Ready

+

+ Generate email-safe HTML that works across all email clients. Verify emails with industry-standard + checks. Our tools are battle-tested and used by thousands of developers. +

+
+
+
+ + {/* CTA */} +
+ +

Need production-grade email tools?

+

+ These free tools are great for development, but Plunk offers so much more: templates, scheduling, + automation, analytics, and deliverability optimization. Start free, scale as you grow. +

+
+ + Start with Plunk + + + View pricing + +
+
+
+
+ +