Maildoubleone

Documentation

Maildoubleone gives you a hosted blog and form backend for any website. Consume it via the JSON API, or drop in the embed widget — no backend required.

Quick Start

1. Create an account

Go to /portal/register and sign up. You'll be asked for your organisation name and the name of your first site. Both are created automatically — no extra configuration needed.

After registration you land directly in your site dashboard.

2. Find your site slug and API key

In the portal, go to Integration & API. Your site slug appears in every endpoint URL:

https://yourdomain.com/api/v1/your-site-slug/posts

Your API key is shown on the same page (reveal and copy). Keep it secret — it's for future management endpoints.

All blog and config API endpoints are read-only and public — no authentication required. Only the management portal requires login.

Blog API

Read-only JSON endpoints. All responses are throttled to 60 requests per minute per IP. Base URL: https://yourdomain.com/api/v1/{site-slug}

GET /posts

Returns a paginated list of published posts.

ParameterTypeDescription
pageintegerPage number (default: 1)
per_pageintegerResults per page, max 50 (default: 6)
categorystringFilter by category slug
tagstringFilter by tag slug
authorstringFilter by author slug
featured1Return only featured posts
searchstringFull-text search in title and excerpt
{
  "data": [
    {
      "id": 12,
      "title": "Getting started with headless blogs",
      "slug": "getting-started-with-headless-blogs",
      "excerpt": "A short summary of the post...",
      "cover_image": "https://yourdomain.com/storage/covers/my-image.jpg",
      "published_at": "2025-05-01T10:00:00+00:00",
      "reading_time_minutes": 4,
      "is_featured": true,
      "view_count": 142,
      "author":     { "name": "Jane Doe", "slug": "jane-doe", "avatar": null },
      "categories": [{ "name": "Tutorials", "slug": "tutorials" }],
      "tags":       [{ "name": "API", "slug": "api" }]
    }
  ],
  "meta": {
    "current_page": 1,
    "last_page": 4,
    "per_page": 6,
    "total": 22
  }
}

GET /posts/{post-slug}

Returns a single published post including full HTML content. Also increments view_count.

{
  "data": {
    "id": 12,
    "title": "Getting started with headless blogs",
    "slug": "getting-started-with-headless-blogs",
    "content": "<p>Full HTML content here...</p>",
    "excerpt": "A short summary...",
    "cover_image": "https://cdn.example.com/covers/my-image.jpg",
    "published_at": "2025-05-01T10:00:00+00:00",
    "reading_time_minutes": 4,
    "is_featured": true,
    "view_count": 143,
    "author":     { "name": "Jane Doe", "slug": "jane-doe", "avatar": null, "bio": "..." },
    "categories": [{ "name": "Tutorials", "slug": "tutorials" }],
    "tags":       [{ "name": "API", "slug": "api" }],
    "seo": {
      "meta_title":       "Custom SEO title (falls back to title)",
      "meta_description": "Custom SEO description (falls back to excerpt)",
      "og_image":         "https://cdn.example.com/og/custom-og.jpg",
      "canonical_url":    "https://yourwebsite.com/blog/getting-started-with-headless-blogs",
      "og_page_url":      "https://yourdomain.com/blog/your-site-slug/getting-started-...",
      "robots":           "index, follow",
      "focus_keyword":    "headless blog",
      "schema_type":      "BlogPosting"
    },
    "json_ld": { "@context": "https://schema.org", "@type": "BlogPosting", "...": "..." }
  }
}

GET /categories

All categories ordered by sort_order then name. Includes published post count.

{
  "data": [
    {
      "id": 3, "name": "Tutorials", "slug": "tutorials",
      "description": null, "cover_image": null,
      "parent_slug": null, "post_count": 8
    }
  ]
}

GET /tags

All tags, alphabetical. Includes published post count.

{ "data": [{ "id": 1, "name": "API", "slug": "api", "post_count": 5 }] }

GET /authors

All active authors. Includes published post count.

{
  "data": [{
    "id": 1, "name": "Jane Doe", "slug": "jane-doe",
    "bio": "Writer and developer.", "avatar": null,
    "website_url": "https://janedoe.com",
    "twitter_handle": "janedoe",
    "linkedin_url": null,
    "post_count": 12
  }]
}

GET /config

Returns the site's branding tokens and embed settings. Use this to theme your own frontend to match the settings configured in the portal.

{
  "data": {
    "site": { "name": "My Blog", "slug": "my-blog", "description": null },
    "branding": {
      "logo_url": null,
      "primary_color": "#1e3a5f",
      "accent_color": "#3b82f6",
      "text_color": "#111827",
      "background_color": "#ffffff",
      "font_heading": "Inter",
      "font_body": "Inter",
      "custom_css": null
    },
    "embed": {
      "blog_theme": "modern",
      "blog_layout": "grid",
      "blog_posts_per_page": 6,
      "blog_show_author": true,
      "blog_show_date": true,
      "blog_show_excerpt": true,
      "blog_show_tags": false,
      "blog_show_cover": true,
      "form_theme": "modern",
      "form_show_labels": true,
      "form_show_placeholders": true,
      "widget_language": "en",
      "blog_url_pattern": "https://yourwebsite.com/blog/{slug}"
    }
  }
}

The embed.blog_url_pattern value is what the blog widget uses to build post card links. Set it in Site Settings → Site Info → Blog post URL pattern.

Forms API

Each form you create gets a unique token. Point your HTML form's action — or your JavaScript fetch — at the submit endpoint. Find your token in Integration & API → Forms.

POST /api/f/{form-token}

HeaderRequiredDescription
Content-TypeYesapplication/x-www-form-urlencoded or application/json
OriginAutoSet by browser; must match your Allowed Domains

Submit any field names — they're stored as-is. Two reserved names:

  • _honeypot — include a hidden empty field with this name to catch bots
  • _redirect — URL to redirect to after successful submission (HTML forms only)

Success response:

{ "success": true, "message": "Submission received." }

HTML form example

<form action="https://yourdomain.com/api/f/YOUR_TOKEN" method="POST">
  <input type="text"  name="name"    placeholder="Your name"     required>
  <input type="email" name="email"   placeholder="Email address" required>
  <textarea           name="message" placeholder="Message"></textarea>

  <!-- Honeypot: hidden from humans, filled in by bots -->
  <input type="text" name="_honeypot"
    style="display:none" tabindex="-1" autocomplete="off">

  <!-- Optional redirect after submit -->
  <input type="hidden" name="_redirect" value="https://yoursite.com/thank-you">

  <button type="submit">Send Message</button>
</form>

JavaScript / fetch example

async function submitForm(event) {
  event.preventDefault();

  const res = await fetch('https://yourdomain.com/api/f/YOUR_TOKEN', {
    method: 'POST',
    body: new FormData(event.target),
  });

  const json = await res.json();

  if (json.success) {
    alert('Message sent!');
  } else {
    console.error(json.errors);
  }
}

document.getElementById('contact-form')
  .addEventListener('submit', submitForm);
To send JSON instead of FormData, set Content-Type: application/json and pass a flat object: body: JSON.stringify({ name: '...', email: '...' })

Blog Widget

The drop-in widget renders your blog directly on any page. It reads your embed settings from the portal automatically — no configuration in the script tag required.

Installation

Add a container element and the script tag anywhere in your HTML:

<!-- Container -->
<div id="e1-blog"></div>

<!-- Widget -->
<script
  src="https://yourdomain.com/widget.js"
  data-site="your-site-slug"
  data-type="blog">
</script>

The widget fetches your posts, applies your branding, and renders with pagination inside #e1-blog.

Script attributes

AttributeRequiredDefaultDescription
data-siteYesYour site slug
data-typeNoblogWidget type
data-targetNoe1-blogID of the container element

Themes & layouts

Configure in Site Settings → Embed Options. Applied automatically to the widget.

SettingOptions
Thememodern · minimal · card · magazine
Layoutgrid · list
Posts per page1 – 50
Show cover / author / date / excerpt / tagson / off per toggle

Custom CSS

In Site Settings → Branding → Custom CSS, write CSS that gets injected into the widget automatically. Override CSS custom properties to change colours without touching anything else:

.e1-widget {
  --e1-primary:    #1e3a5f;   /* primary colour */
  --e1-accent:     #3b82f6;   /* links and highlights */
  --e1-text:       #111827;   /* body text */
  --e1-bg:         #ffffff;   /* card background */
  --e1-font-head:  'Playfair Display', serif;
  --e1-font-body:  'Inter', sans-serif;
}

All widget elements use the .e1- class prefix — they won't clash with your site's existing CSS.

Social Sharing

Automatically post to Twitter/X, LinkedIn and Facebook when you publish a blog post. Sharing is per-post and fully opt-in — you control which posts get shared and what the message says.

1. Connect your social accounts

In the portal, go to Settings → Social Accounts. You'll see a card for each platform — Twitter/X, LinkedIn and Facebook Page. Click Connect on any platform and you'll be taken through that platform's standard OAuth login flow.

Facebook is different: after you log in with your Facebook account, you'll be shown a list of all the Facebook Pages you manage. Select the Page you want posts shared to and click Connect Selected Page. Posts cannot be shared to a personal Facebook profile — a Page is required. You must be an admin of the Page.

Each site has its own set of connected accounts. Connecting an account on one site does not affect your other sites.

The Recent Shares table at the bottom of the same page shows the status of every past share — published, failed, or skipped — along with any error details.

2. Set your blog post URL

Before enabling sharing, tell the platform where blog posts live on your website. Go to Settings → Site Settings → Site Info and fill in the Blog post URL pattern field:

https://yourwebsite.com/blog/{slug}

Replace the path (/blog/) with whatever path your website uses. The {slug} part is replaced automatically with each post's slug. This URL is what appears in every social share and is used as the {url} placeholder below.

If this field is left blank, the platform falls back to your first Allowed Domain + /blog/{slug}. Setting it explicitly is recommended.

3. Enable auto-share on a post

When editing or creating a blog post, open the Social Sharing section in the right sidebar. Toggle Auto-share when published on. That's it — when you change the post status to Published and save, the share is dispatched to all connected accounts automatically.

Sharing only happens once — on the first publish. Re-saving a published post will not re-share it. Scheduled posts (published automatically via the scheduler) also respect this toggle.

If a connected account's access token has expired, that platform is skipped and recorded as skipped in the share history. Reconnect the account in Settings → Social Accounts and re-publish if needed.

4. Customise the share message

Leave the Custom share message field blank to use the default:

{title} {url}

Or write your own message using these placeholders:

PlaceholderReplaced with
{title}The post title
{url}The full URL to the post on your website, built from your Blog post URL pattern (see step 2 above)
{excerpt}The post excerpt (if set)

Example custom message:

New post: {title} — read it on our blog 👇
{url}

#webdev #blogging
Twitter/X has a 280-character limit. Messages longer than 280 characters are automatically trimmed with ....

Social Preview

Open the Social Preview section in the post editor sidebar to see a mock-up of how your post will appear as a link card on Twitter/X, LinkedIn and Facebook. The preview uses your post title and excerpt (or the SEO meta title / description if set) and your cover image. It updates as you edit — no need to save first.

SEO & Open Graph

Every published post exposes a rich seo object and structured data (JSON-LD) through the API. Use these to populate <meta> tags so that when a post URL is shared on social media — Twitter/X, LinkedIn, Facebook, WhatsApp, iMessage — the platform renders a proper link card with your title, excerpt and cover image.

Open Graph tags must be rendered as static HTML on the server. JavaScript that sets tags after page load is ignored by social crawlers. The sections below describe how to do this correctly in each setup.

SEO fields returned by /posts/{post-slug}

The single-post endpoint returns a seo object alongside the post data. Fill in these values in the portal's Blog Posts → Edit → SEO tab. All fields fall back gracefully when left blank.

{
  "data": {
    "id": 12,
    "title": "Getting started with headless blogs",
    "slug": "getting-started-with-headless-blogs",
    "excerpt": "A short summary of the post...",
    "cover_image": "https://cdn.example.com/covers/my-image.jpg",
    "content": "<p>Full HTML content...</p>",
    "published_at": "2025-05-01T10:00:00+00:00",
    "reading_time_minutes": 4,
    "is_featured": false,
    "author": { "name": "Jane Doe", "slug": "jane-doe", "avatar": null },
    "categories": [{ "name": "Tutorials", "slug": "tutorials" }],
    "tags": [{ "name": "API", "slug": "api" }],

    "seo": {
      "meta_title":       "Custom <title> tag (falls back to post title)",
      "meta_description": "Custom meta description (falls back to excerpt)",
      "og_image":         "https://cdn.example.com/og/my-og.jpg",
      "canonical_url":    "https://yourwebsite.com/blog/getting-started-with-headless-blogs",
      "og_page_url":      "https://maildoubleone.com/blog/your-site-slug/getting-started-...",
      "robots":           "index, follow",
      "focus_keyword":    "headless blog API",
      "schema_type":      "BlogPosting"
    },

    "geo": {
      "ai_summary":          "One-paragraph AI summary for AI search engines",
      "ai_key_takeaways":    ["Takeaway one", "Takeaway two"],
      "ai_target_questions": ["What is a headless blog?"],
      "ai_citations":        ["https://source.example.com"]
    },

    "json_ld": { "@context": "https://schema.org", "@type": "BlogPosting", "..." : "..." }
  }
}
FieldFalls back toNotes
seo.meta_titletitleUse as <title> and og:title
seo.meta_descriptionexcerptUse as <meta name="description"> and og:description
seo.og_imagecover_imageUse as og:image and twitter:image. Recommended size: 1200 × 630 px.
seo.canonical_urlblog_url_pattern + slugSet this if the post lives at a specific URL on your site
seo.og_page_urlThe hosted OG fallback page on this platform (see below)
seo.robotsindex, followPass directly to <meta name="robots">
json_ldReady-to-embed JSON-LD schema. Inline in a <script type="application/ld+json"> tag

Blog URL pattern

Go to Settings → Site Settings → Site Info and set the Blog post URL pattern field:

https://yourwebsite.com/blog/{slug}

This single setting has three effects:

  • Widget links — the embed widget reads this from /config → embed.blog_url_pattern and uses it to build post card links, so clicks land on your site instead of the OG fallback page.
  • Social share URLs — the {url} placeholder in auto-share messages resolves to https://yourwebsite.com/blog/your-post-slug.
  • Canonical URL — when seo.canonical_url is not set on a post, the API derives it from this pattern and returns it in the seo object for your SSR layer to use.
You can also override the widget URL on a per-embed basis with the data-post-url script attribute: data-post-url="https://yoursite.com/blog/{slug}". This takes priority over the portal setting.

Hosted OG fallback page

Every post automatically gets a server-rendered page hosted on this platform at:

https://yourdomain.com/blog/{site-slug}/{post-slug}

This page outputs all Open Graph, Twitter Card and JSON-LD tags as static HTML, so social crawlers can read them without JavaScript. When a real user visits the URL, they are 301-redirected to your site's canonical URL (if set) — they never see the fallback page.

The widget uses this URL as the default post-card link when no Blog post URL pattern is configured. For best results — so that post links on your own site carry the right OG tags — configure the URL pattern and implement server-side meta tags on your own frontend (see below).

If you rely on the hosted OG page for sharing, ensure you have not set a canonical URL that redirects before crawlers can read the tags. The hosted page is the safest default; migrating to your own SSR is recommended for full control over appearance.

Next.js App Router

Next.js generateMetadata runs on the server, which means crawlers receive the full <head> without executing JavaScript. The key requirement is that your blog post page must be a server component (no "use client" at the top).

Step 1 — server-side fetch helper (in lib/blog-api.ts):

// Uses native fetch so Next.js can cache/revalidate the response
export async function fetchPostBySlugServer(slug: string): Promise<BlogPost | null> {
  const apiBase = process.env.NEXT_PUBLIC_BLOG_API_URL;
  const res = await fetch(`${apiBase}/your-site-slug/posts/${slug}`, {
    next: { revalidate: 300 }, // cache for 5 minutes
  });
  if (!res.ok) return null;
  const data = await res.json();
  return mapPost(data?.data ?? data);
}

Step 2 — server component with generateMetadata (app/blog/[slug]/page.tsx):

// No "use client" here — this is a server component
import type { Metadata } from "next";
import { fetchPostBySlugServer } from "@/lib/blog-api";
import BlogPostClient from "./BlogPostClient"; // "use client" lives here

type Props = { params: Promise<{ slug: string }> };

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params;
  const post = await fetchPostBySlugServer(slug);

  if (!post) return { title: "Post Not Found", robots: { index: false, follow: false } };

  const title       = post.seo?.meta_title       || post.title;
  const description = post.seo?.meta_description || post.excerpt;
  const ogImage     = post.seo?.og_image         || post.coverImage;
  const canonical   = post.seo?.canonical_url    || `https://yoursite.com/blog/${post.slug}`;
  const robots      = post.seo?.robots           || "index, follow";

  return {
    title,
    description,
    robots,
    alternates: { canonical },
    openGraph: {
      type:          "article",
      title,
      description,
      url:           canonical,
      siteName:      "Your Site Name",
      publishedTime: post.publishedAt ?? undefined,
      authors:       post.author?.name ? [post.author.name] : [],
      images: ogImage
        ? [{ url: ogImage, width: 1200, height: 630, alt: title }]
        : [{ url: "/images/og-default.png", width: 1200, height: 630, alt: title }],
    },
    twitter: {
      card:        "summary_large_image",
      title,
      description,
      images: ogImage ? [ogImage] : ["/images/og-default.png"],
    },
  };
}

export default async function BlogPostPage({ params }: Props) {
  const { slug } = await params;
  const post = await fetchPostBySlugServer(slug);
  return <BlogPostClient initialPost={post} />;
}

Step 3 — move UI to a client component (app/blog/[slug]/BlogPostClient.tsx):

"use client";

import { fetchPostBySlug, type BlogPost } from "@/lib/blog-api";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";

export default function BlogPostClient({ initialPost }: { initialPost: BlogPost | null }) {
  const { slug } = useParams<{ slug: string }>();
  const [post, setPost] = useState(initialPost);

  // Only fetches client-side if SSR didn't return the post (e.g. navigation without reload)
  useEffect(() => {
    if (initialPost || !slug) return;
    fetchPostBySlug(slug).then(setPost).catch(() => setPost(null));
  }, [slug, initialPost]);

  if (!post) return <p>Post not found.</p>;

  return (
    <article>
      <h1 className="post-title">{post.title}</h1>
      <p className="post-excerpt">{post.excerpt}</p>
      {/* render post.content safely */}
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
The post-title, post-excerpt and post-summary CSS classes are referenced by the JSON-LD speakable property returned by the API, which helps Google and AI assistants identify the key readable parts of the page.

Set NEXT_PUBLIC_BLOG_API_URL in your .env.local to point at your E1 backend:

NEXT_PUBLIC_BLOG_API_URL=https://yourdomain.com/api/v1

Other frameworks

Nuxt 3 — use useHead() or useSeoMeta() inside a page component. Fetch the post in useFetch() or useAsyncData() so the data is available during SSR:

<script setup lang="ts">
const route = useRoute();
const { data: response } = await useFetch(
  `${config.public.apiBase}/your-site-slug/posts/${route.params.slug}`
);
const post = response.value?.data;

useSeoMeta({
  title:           post?.seo?.meta_title       ?? post?.title,
  description:     post?.seo?.meta_description ?? post?.excerpt,
  ogImage:         post?.seo?.og_image         ?? post?.cover_image,
  ogType:          'article',
  twitterCard:     'summary_large_image',
});
</script>

SvelteKit — return data from the +page.server.ts load function, then set meta tags in the <svelte:head> block:

// +page.server.ts
export async function load({ params, fetch }) {
  const res = await fetch(`${API_BASE}/your-site-slug/posts/${params.slug}`);
  const { data: post } = await res.json();
  return { post };
}

// +page.svelte
<script>export let data;</script>
<svelte:head>
  <title>{data.post.seo?.meta_title ?? data.post.title}</title>
  <meta name="description" content={data.post.seo?.meta_description ?? data.post.excerpt}>
  <meta property="og:image" content={data.post.seo?.og_image ?? data.post.cover_image}>
  <meta name="twitter:card" content="summary_large_image">
</svelte:head>

Plain HTML / static sites — if your site has no server-side rendering, use the hosted OG page as the shareable URL. Widget card links point there by default when no blog_url_pattern is set. Social crawlers will read the tags from the hosted page; humans are redirected to your canonical URL.

Inline JSON-LD — the API returns a ready-made schema in the json_ld field. Embed it in your <head> on any framework:

<!-- In your <head> -->
<script type="application/ld+json">
  <!-- paste the json_ld object from the API response verbatim -->
</script>

In Next.js you can pass it as a string directly:

// In generateMetadata or a <Head> component:
<script
  type="application/ld+json"
  dangerouslySetInnerHTML={{ __html: JSON.stringify(post.json_ld) }}
/>

CORS & Allowed Domains

The form submit endpoint enforces CORS. By default (no domains configured) all origins are allowed. Once you add at least one domain in Integration & API → Allowed Domains, only those origins can submit.

https://mywebsite.com    ← exact origin
*.mywebsite.com          ← all subdomains
The blog read API (/api/v1/{slug}/...) is always public. CORS restrictions apply only to form submissions.

Spam Protection

Add a hidden text field named _honeypot to your form. Real users won't see or fill it in; bots will. Any submission with a non-empty honeypot is stored as spam and won't trigger an email notification.

<input type="text" name="_honeypot"
  style="display:none; position:absolute; left:-9999px"
  tabindex="-1" autocomplete="off">

Spam submissions are still stored so you can review them. Filter to spam-only in Submissions → Filters → Spam: Spam only.

Error Reference

StatusMeaning
200Success
404Site slug, post slug, or form token not found
422Validation error — check errors in the response body
429Rate limit exceeded — slow down requests
403CORS blocked — add your domain in Site Settings
// 404
{ "error": "Site not found" }

// 422
{
  "message": "The given data was invalid.",
  "errors": { "email": ["The email field is required."] }
}