How it works Examples Docs Pricing Blog WP Blocks Install free

Blog /

Build a contact form that ships email through your existing SMTP plugin

Contact forms are the single most-installed plugin category on WordPress. Three of them have over a million installs apiece. The reason there are three of them with a million installs each (and not one with three million) is that the actual rendering and styling and “make it look like our brand” part is the part each site needs to customize, and there’s no clean way to do that without forking the plugin or fighting a page builder. The category accumulates new entrants because the existing ones have all crystallized around their particular UI choices, and a new site picks whichever one matches what they want today.

DesignSetGo Apps inverts the problem. The form is your bundle. The email sending is one bridge call. SMTP delivery is whatever the site already has installed. The plugin doesn’t ship a UI and isn’t trying to be a forms-builder; it ships a runtime, and you build whatever form you want inside it.

This post walks through the examples/contact bundle. Read through, then deploy it and customize.

What you build

A single-page contact form. Visitors fill it in, hit send, the form posts to dsgo.email.send, the bridge routes through wp_mail, the message arrives in the site admin’s inbox. If the site has WP Mail SMTP, FluentSMTP, or any other SMTP plugin installed, the message goes through that path with no extra configuration. The contact-form app doesn’t know or care; wp_mail is the seam, and every SMTP plugin in the WP ecosystem hooks wp_mail.

The manifest

{
  "manifest_version": 1,
  "id": "contact",
  "name": "Contact form",
  "description": "Visitor-facing contact form that emails the site admin via dsgo.email.send.",
  "version": "0.1.0",
  "entry": "index.html",
  "isolation": "iframe",
  "display": { "modes": ["page"], "default": "page" },
  "permissions": { "read": ["email"], "write": [] },
  "email": { "recipients": ["admin"] },
  "runtime": { "sandbox": "strict", "external_origins": [] }
}

A few things are worth pointing at:

  • The install dialog shows one bucket: “Send messages.” The permissions.read: ["email"] plus the email.recipients block triggers the Send-messages bucket; the dialog’s plain-English row reads “This app can send email through your site’s mailer.” That’s the entire trust contract from the site owner’s perspective.
  • email.recipients: ["admin"] is a manifest-declared allowlist of who the app is allowed to send to. "admin" resolves at runtime to the site admin email; the app cannot pass an arbitrary to: address. This is the trust boundary that keeps a contact-form app from being repurposed as a spam relay. If a future version of your form needs to send to a configurable address (a sales inbox, a support inbox), you’d add that address to the recipients list explicitly, and the install dialog would update to reflect the change.
  • runtime.external_origins: [] means the app makes no third-party network calls. The bridge handles everything; the iframe’s CSP forbids the rest. If you change your form to load a CAPTCHA from a third party, you’d add the CAPTCHA’s origin here, and the install dialog would surface it.

The form

The HTML is the part you customize for your brand. Markup, CSS, layout, copy, validation rules: all yours. The example bundle uses a deliberately stylized “letterhead” look (the form fields are numbered with Roman numerals, the submit button has a wax-seal M), but it’s a single index.html file plus a stylesheet; rip it out and replace with whatever you want. The interesting piece is the submit handler. From examples/contact/app.js, with a few details elided for clarity:

const dsgo = window.dsgo;

const form = document.getElementById('form');
const statusEl = document.getElementById('status');
const submit = document.getElementById('submit');

form.addEventListener('submit', async (e) => {
  e.preventDefault();
  const data = Object.fromEntries(new FormData(form));

  if (!data.name || !data.email || !data.subject || !data.message) {
    statusEl.textContent = 'Please fill out every field.';
    return;
  }

  submit.disabled = true;
  statusEl.textContent = 'Sending...';

  try {
    await dsgo.email.send({
      to: 'admin',
      subject: 'Contact form: ' + data.subject,
      body: [
        'Name:    ' + data.name,
        'Email:   ' + data.email,
        '',
        '----- Message -----',
        data.message,
      ].join('\n'),
      replyTo: data.email,
    });
    form.reset();
    statusEl.textContent = 'Thanks. We received your message and will reply by email.';
  } catch (err) {
    const code = err && err.code ? ' (' + err.code + ')' : '';
    statusEl.textContent = 'Could not send: ' + (err.message || 'Unknown error') + code;
  } finally {
    submit.disabled = false;
  }
});

That’s the whole sending half. Note the replyTo field on the bridge call: it’s set to the visitor’s email so the site admin can hit Reply in their mail client and write directly to the visitor. Plain-text body, no HTML to escape, no marketing-template chrome. The bridge handles authentication, nonce, and the wp_mail call.

A few small details worth pointing out, because they’re easy to miss:

  • dsgo.email.send returns a resolved promise on success and rejects with a structured error on failure. The error object has code (a string like permission_denied, invalid_recipient, transport_error) and message (a human-readable description). Logging the code is more useful than logging the message; the codes are stable, the messages are not.
  • No nonce, no headers, no auth. The bridge handshake at await dsgo.ready already authenticated the iframe to the parent. Every subsequent call inherits that authentication. Your code doesn’t manage tokens.
  • finally re-enables the submit button. Easy to forget; the visitor double-tapping a disabled button after a network failure is a worse UX than a slightly slow response.

What you didn’t have to write

A short, deliberately complete list of things this app skips:

  • An SMTP layer. The site’s existing SMTP plugin (if any) handles delivery. If there isn’t one, wp_mail falls back to PHP’s mail(), which works fine for low-volume sites and badly for high-volume ones. For sites that send any meaningful volume, install WP Mail SMTP or FluentSMTP; this app inherits whichever you pick.
  • A spam filter. The email.recipients allowlist prevents the app from being weaponized as a relay. For real spam protection (form-submission rate limiting, CAPTCHA, honeypot fields), you’d layer Akismet or Cloudflare Turnstile on top; this app’s scope is “deliver the message reliably and do nothing else.”
  • A reply-to handling story. replyTo in the bridge call sets the right header so the admin’s email client offers the visitor’s address as the reply target. No extra configuration.
  • A “save submissions to the database” feature. If the site needs that, it’s a different app, or a future v1.x bridge method, or a hook into a forms-storage plugin. This app’s scope is delivery; storage is somebody else’s problem.

Why this beats a forms-builder plugin

A short comparison:

  • The bundle is a single .zip you can read end-to-end in an evening. A real contact-form plugin is forty thousand lines of PHP across a hundred files plus a half-dozen extension plugins, half of which are upsells.
  • The site owner sees exactly one capability in the install dialog: “Send messages.” No add-ons, no upsell drawers, no “Pro version unlocks SMS notifications.”
  • The form looks however you want. Customizing markup is editing HTML, not subclassing a builder.
  • If something breaks, the failure is contained to one iframe. Worst case, you delete the app; the site keeps running. A real forms-builder plugin breaking takes down the entire forms surface of the site.
  • Updating it is npx designsetgo apps deploy --build. Not a plugin update, not a database migration, not an admin notice.

The trade: you write the form. That’s not free. But the plugins you’d otherwise reach for are designed for non-developers, and as a developer you’re paying for that audience choice every time you fight the builder. If you’re already comfortable in HTML, the bundle is faster.

A small troubleshooting tour

A few things that go wrong on first deploy, and what to do:

“My SMTP plugin isn’t intercepting the message.” Most likely the SMTP plugin is hooking wp_mail only when a From: header is present. The bridge call doesn’t set one explicitly; the SMTP plugin uses its configured From address. Check the plugin’s logs to confirm it received the call.

“The message arrives, but the From address is wp@yoursite.com.” That’s the WordPress default. To set a custom From, configure it in your SMTP plugin (preferred) or hook wp_mail_from and wp_mail_from_name from a small companion plugin. The contact app doesn’t try to override From; that’s a site-wide policy decision.

“The Reply-To header isn’t being honored.” A handful of SMTP plugins strip or rewrite Reply-To for deliverability reasons. If yours does, you can include the visitor’s email in the body itself (the example does this, in case the header gets stripped) and the admin can copy/paste from there.

“The bridge call rejects with permission_denied.” Double-check the manifest: permissions.read should include email, and email.recipients should be a non-empty array. If recipients is missing entirely, the bridge defaults to denying.

Deploy

git clone https://github.com/DesignSetGo/dsgo-apps.git
cd dsgo-apps/examples/contact
zip -r contact.zip .

Upload contact.zip through the DSGo Apps admin page (Apps → Add new → Upload). Approve the install dialog (one row: Send messages). The form is now at https://yoursite.com/apps/contact/. Drop a link to it in your nav, or delete the existing contact-form plugin and embed this one in the same spot.

If you have the CLI configured, you can skip the manual upload:

npx designsetgo apps deploy --build

Same result, no clicking. Subsequent deploys against the same slug update the app in place; the URL stays stable.

Where to go next

  • The bridge cookbook covers dsgo.email.send alongside nine other patterns.
  • The bridge API reference has the full email.send signature, error codes, and rate-limit notes.
  • The next post in this calendar walks through a logged-in member portal, which combines dsgo.user.current(), dsgo.posts.list(), and dsgo.storage.user.* into a single app.