Skip to content

Instantly share code, notes, and snippets.

@NeroxTGC
Last active August 12, 2025 09:17
Show Gist options
  • Select an option

  • Save NeroxTGC/4f20a10f41e00d0382003b22331a9049 to your computer and use it in GitHub Desktop.

Select an option

Save NeroxTGC/4f20a10f41e00d0382003b22331a9049 to your computer and use it in GitHub Desktop.

Mailgun + Wasp: Setting Reply-To (Custom Headers)

Use Mailgun in a Wasp app and set a Reply-To header. Wasp’s built-in email sender doesn’t expose provider headers, so we call Mailgun’s REST API with a tiny server helper and reuse Wasp env vars.

Why? (Mailgun domain best practices)

image

Notes about Wasp auth emails

  • Verification and reset emails accept only subject/text/html via getEmailContentFn.
  • No custom headers there (so reply-to field cannot be used).

TL;DR

  • Use a small helper that posts to Mailgun and sets h:Reply-To.
  • Reuse Wasp envs: MAILGUN_API_KEY, MAILGUN_DOMAIN, MAILGUN_API_URL (EU optional).

Env vars

Add to .env.server.

MAILGUN_API_KEY=your_api_key
MAILGUN_DOMAIN=email.domain.com
# Optional EU endpoint
MAILGUN_API_URL=https://api.eu.mailgun.net

Server helper (Mailgun)

Supports Reply-To via h:Reply-To.

Location: app/src/server/email/mailgun.ts

Signature:

sendMailgunEmail({
  from: string,
  to: string,
  subject: string,
  text?: string,
  html?: string,
  replyTo?: string,
})

Example (server-side):

import { sendMailgunEmail } from '@src/server/email/mailgun';

await sendMailgunEmail({
  from: 'Dude <hello@email.domain.com>',
  to: 'user@example.com',
  subject: 'Hello!',
  html: '<p>Hi there 👋</p>',
  replyTo: 'contact@domain.com',
});

Optional: test page and action

Handy for manual tests:

  • Page: EmailTestPage at /email-test (requires auth)
  • Action: sendTestEmail in app/src/email-test/operations.ts

Client usage:

import { useAction, sendTestEmail } from 'wasp/client/operations';

const send = useAction(sendTestEmail);
await send({
  fromName: 'Dude',
  fromEmail: 'hello@email.domain.com',
  to: 'user@example.com',
  subject: 'Hello',
  body: '<p>Hello world</p>',
  isHtml: true,
  replyTo: 'contact@domain.com',
});
image
type SendMailgunEmailParams = {
from: string;
to: string;
subject: string;
text?: string;
html?: string;
replyTo?: string;
};
type MailgunSendResponse = {
id?: string;
message?: string;
[key: string]: unknown;
};
export async function sendMailgunEmail(params: SendMailgunEmailParams): Promise<MailgunSendResponse> {
const {
from,
to,
subject,
text,
html,
replyTo,
} = params;
const apiKey = process.env.MAILGUN_API_KEY;
const domain = process.env.MAILGUN_DOMAIN;
const apiUrl = process.env.MAILGUN_API_URL || 'https://api.mailgun.net';
if (!apiKey) throw new Error('MAILGUN_API_KEY is not set');
if (!domain) throw new Error('MAILGUN_DOMAIN is not set');
const endpoint = `${apiUrl}/v3/${domain}/messages`;
const form = new FormData();
form.append('from', from);
form.append('to', to);
form.append('subject', subject);
if (text) form.append('text', text);
if (html) form.append('html', html);
if (replyTo) form.append('h:Reply-To', replyTo);
const authHeader = 'Basic ' + Buffer.from(`api:${apiKey}`).toString('base64');
const resp = await fetch(endpoint, {
method: 'POST',
headers: {
Authorization: authHeader,
},
body: form,
});
const isJson = resp.headers.get('content-type')?.includes('application/json');
const data = (isJson ? await resp.json() : await resp.text()) as MailgunSendResponse;
if (!resp.ok) {
const reason = typeof data === 'string' ? data : JSON.stringify(data);
throw new Error(`Mailgun send failed (${resp.status}): ${reason}`);
}
return data;
}
/**
* Wasp Action implementation.
*
* IMPORTANT: Declare this Action in main.wasp BEFORE using it
* from 'wasp/client/operations' or 'wasp/server/operations':
*
* action sendTestEmail {
* fn: import { sendTestEmail } from "@src/email-test/operations.ts"
* }
*
* After declaration, Wasp generates the callable helpers:
* - Client: import { sendTestEmail } from 'wasp/client/operations'
* - Server: import { sendTestEmail } from 'wasp/server/operations'
*
* Note: This Action does not use entities.
*/
import { sendMailgunEmail } from '../server/email/mailgun';
type SendTestEmailArgs = {
fromName: string;
fromEmail: string;
to: string;
subject: string;
body: string;
isHtml?: boolean;
replyTo?: string;
};
export async function sendTestEmail(args: SendTestEmailArgs): Promise<{ id?: string; message?: string }> {
const { fromName, fromEmail, to, subject, body, isHtml, replyTo } = args;
if (!fromEmail || !to || !subject || !body) {
throw new Error('Missing required fields.');
}
const from = `${fromName ? fromName : 'Messync'} <${fromEmail}>`;
const res = await sendMailgunEmail({
from,
to,
subject,
text: isHtml ? undefined : body,
html: isHtml ? body : undefined,
replyTo,
});
return { id: res.id as string | undefined, message: res.message as string | undefined };
}
/**
* Shadcn used
*/
import { useState } from 'react';
import { useAction } from 'wasp/client/operations';
import { sendTestEmail } from 'wasp/client/operations';
import { Button } from '../components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../components/ui/card';
import { Input } from '../components/ui/input';
import { Label } from '../components/ui/label';
import { Switch } from '../components/ui/switch';
import { Textarea } from '../components/ui/textarea';
export default function EmailTestPageExample() {
const send = useAction(sendTestEmail);
const [fromName, setFromName] = useState('Example');
const [fromEmail, setFromEmail] = useState('hello@email.domain.com');
const [to, setTo] = useState('');
const [replyTo, setReplyTo] = useState('contact@domain.com');
const [subject, setSubject] = useState('Hello from Example');
const [isHtml, setIsHtml] = useState(true);
const [body, setBody] = useState('<p>Hi there 👋</p>');
const [sending, setSending] = useState(false);
const [result, setResult] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSending(true);
setError(null);
setResult(null);
try {
const res = await send({ fromName, fromEmail, to, subject, body, isHtml, replyTo });
setResult(res?.message || 'Sent');
} catch (err: any) {
setError(err?.message || 'Failed to send');
} finally {
setSending(false);
}
};
return (
<div className="container mx-auto max-w-3xl p-4">
<Card className="shadow-sm">
<CardHeader>
<CardTitle>Email Composer</CardTitle>
<CardDescription>Quick test page to send emails (with Reply-To).</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={onSubmit} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="fromName">From Name</Label>
<Input id="fromName" value={fromName} onChange={(e) => setFromName(e.target.value)} />
</div>
<div>
<Label htmlFor="fromEmail">From Email</Label>
<Input id="fromEmail" type="email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<Label htmlFor="to">To</Label>
<Input id="to" type="email" value={to} onChange={(e) => setTo(e.target.value)} required />
</div>
<div>
<Label htmlFor="replyTo">Reply-To</Label>
<Input id="replyTo" type="email" value={replyTo} onChange={(e) => setReplyTo(e.target.value)} />
</div>
</div>
<div>
<Label htmlFor="subject">Subject</Label>
<Input id="subject" value={subject} onChange={(e) => setSubject(e.target.value)} required />
</div>
<div className="flex items-center space-x-2">
<Switch id="isHtml" checked={isHtml} onCheckedChange={setIsHtml} />
<Label htmlFor="isHtml">HTML</Label>
</div>
<div>
<Label htmlFor="body">Body</Label>
<Textarea id="body" value={body} onChange={(e) => setBody(e.target.value)} rows={10} />
</div>
<div className="flex gap-2">
<Button type="submit" disabled={sending}>{sending ? 'Sending...' : 'Send'}</Button>
</div>
{result && (
<p className="text-sm text-green-600 dark:text-green-400">{result}</p>
)}
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
</form>
</CardContent>
</Card>
</div>
);
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment