TPH Mailer

Create and send campaigns to your audience — powered by Resend

Settings
Audience

Add test recipients (no CSV needed)

Add yourself and a teammate first, send a test, then upload the full CSV when ready.

Or upload full list

Upload CSV

Drop file or click to browse

New uploads add to your list (duplicates skipped)

Subject Line
"Dear {name}," · nameless → "Dear sir/ma,"
Compose

Paste a designed HTML email (from Mailchimp, Beefree, etc.) in the HTML tab. Full templates are sent as-authored — only merge tags are replaced. Greeting is turned off automatically for designed HTML.

Merge Tags

Click to insert into editor

{{first_name}} {{last_name}} {{name}} {{email}}
Attachments
Drop files or click
Inbox Preview
Preview as:
Width:
Send Campaign

Uses Resend Batch API when proxy v1.2.0+ is installed (no attachments). Progress auto-saves — use Continue to skip already-sent addresses.

Checking mailer proxy…

0
Sent
0
Failed
0
Left
AI Assistant
Upload a CSV first, then ask AI to extract or clean contacts.
Your email preview will appear here'; } if(isDesignedHtml(html)||/]/i.test(html))return html; return ' '+html+''; } function rawHtml(c){return composeHtml(c)} function plainTxt(c){ if(activeTab==="text")return greet(c,false)+mg(edT.value||"",c,false); return ""; } function updPrev(){ if(!prevFrame)return; var i=parseInt(prevSel.value)||0; var c=contacts[i]||{email:"recipient@example.com",first_name:"John",last_name:"Doe"}; var html=activeTab==="text" ? wrapPreviewDoc("
"+esc(plainTxt(c)||"(empty)")+"
") : wrapPreviewDoc(composeHtml(c)); prevFrame.srcdoc=html; if(prevMeta){ var from=(fName.value.trim()||"This Present House")+" <"+esc(fEmail.value.trim()||"info@thispresenthouse.org")+">"; var subj=esc(mg(subjEl.value.trim()||"(no subject)",c,false)); prevMeta.innerHTML='From '+from+'Subject '+subj+'To '+esc(c.email)+''; } } prevSel.addEventListener("change",updPrev); /* ═══ SEND ═══ */ function addLog(m,t){var e=document.createElement("div");e.className="ml-le"+(t?" is-"+t:"");e.textContent=new Date().toLocaleTimeString()+" — "+m;logEl.appendChild(e);logEl.scrollTop=logEl.scrollHeight;logEl.classList.add("has")} function restBase(){ if(window.wpApiSettings&&window.wpApiSettings.root)return window.wpApiSettings.root.replace(/\/?$/,"/"); var link=document.querySelector('link[rel="https://api.w.org/"]'); if(link&&link.href)return link.href.replace(/\/?$/,"/"); return window.location.origin.replace(/\/$/,"")+"/wp-json/"; } var sendUrl=restBase()+"tph-mailer/v1/send",batchUrl=restBase()+"tph-mailer/v1/send-batch",pingUrl=restBase()+"tph-mailer/v1/ping",proxyOk=false,proxyBatch=false; function setProxyStatus(ok,msg){proxyOk=!!ok;var el=$("proxy-status");if(!el)return;el.textContent=msg;el.style.color=ok?"var(--ok)":"var(--err)"} fetch(pingUrl,{method:"GET",credentials:"same-origin"}).then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json()}).then(function(d){ if(d&&d.ok){proxyBatch=!!d.batch;setProxyStatus(true,"Proxy ready (v"+(d.version||"?")+") — Resend Batch API"+(proxyBatch?"":" (upload tph-mailer.php 1.2.0 for batch)"));} else setProxyStatus(false,"Mailer proxy not ready. Upload tph-mailer.php to mu-plugins."); }).catch(function(){setProxyStatus(false,"Mailer proxy missing. Upload server/tph-mailer.php to wp-content/mu-plugins/")}); function errMsg(d,status){if(!d)return "HTTP "+status;if(typeof d==="string")return d;if(d.message)return d.message;return "HTTP "+status} function clampBatch(v){var n=parseInt(v,10);if(isNaN(n)||n<1)n=50;return Math.min(100,n)} function clampConc(v){var n=parseInt(v,10);if(isNaN(n)||n<1)n=3;return Math.min(8,n)} $("b-size").addEventListener("change",function(){$("b-size").value=clampBatch($("b-size").value);scheduleSave()}); $("b-size").addEventListener("blur",function(){$("b-size").value=clampBatch($("b-size").value)}); $("b-conc").addEventListener("change",function(){$("b-conc").value=clampConc($("b-conc").value);scheduleSave()}); $("b-conc").addEventListener("blur",function(){$("b-conc").value=clampConc($("b-conc").value)}); $("b-delay").addEventListener("change",scheduleSave); function isRetryable(msg){msg=(msg||"").toLowerCase();return /too many|rate|timeout|temporar|network|failed to fetch|502|503|504|429|aborted/.test(msg)} function isAuthError(msg){msg=(msg||"").toLowerCase();return /api key is invalid|invalid.?api.?key|unauthorized|missing_key|valid resend api key|authentication|not authorized|account.*suspend|marked for investigation/.test(msg)} function fetchWithTimeout(url,opts,ms){ var ctrl=new AbortController(); var t=setTimeout(function(){ctrl.abort()},ms||60000); var o=Object.assign({},opts,{signal:ctrl.signal}); return fetch(url,o).finally(function(){clearTimeout(t)}); } async function postJson(url,pl,retries,timeoutMs){ retries=retries==null?2:retries;var lastErr="Unknown error"; for(var attempt=0;attempt<=retries;attempt++){ if(stopReq)throw new Error("Stopped"); try{ var r=await fetchWithTimeout(url,{method:"POST",credentials:"same-origin",headers:{"Content-Type":"application/json"},body:JSON.stringify(pl)},timeoutMs||60000); var d=null;try{d=await r.json()}catch(x){d=null} if(r.ok&&d&&d.ok)return d; lastErr=errMsg(d,r.status); if(isAuthError(lastErr)||r.status===401||r.status===403){var ae=new Error(lastErr);ae.isAuth=true;throw ae} if(r.status===400||r.status===422)throw new Error(lastErr); if(!isRetryable(lastErr)&&r.status!==429&&r.status<500)throw new Error(lastErr); }catch(e){ if(e&&e.isAuth)throw e; lastErr=e&&e.name==="AbortError"?"Timeout":(e&&e.message?e.message:"Failed to fetch"); if(lastErr==="Stopped")throw e; if(lastErr==="Failed to fetch")lastErr="Network error"; if(isAuthError(lastErr)){var ae2=new Error(lastErr);ae2.isAuth=true;throw ae2} if(!isRetryable(lastErr)&&attempt===0)throw new Error(lastErr); } if(attempt>>0} return "tph-"+h.toString(36)+"-"+s.length.toString(36); } function attSummary(){ var list=attachments.filter(function(a){return a&&a.content}); if(!list.length)return ""; var total=0;list.forEach(function(a){total+=a.size||0}); return list.map(function(a){return (a.filename||a.name||"file")+" ("+fmtSz(a.size||0)+")"}).join(", ")+" — total "+fmtSz(total); } function startSend(queue,mode){ var key=rKey.value.trim();if(!key){toast("Enter Resend key in Settings");return} if(key.indexOf("re_")!==0){toast("Resend key should start with re_");return} var fn=fName.value.trim()||"This Present House",fe=fEmail.value.trim();if(!fe){toast("Set From email");return} if(!queue.length){toast("Nothing left to send");return} doSend(key,fn,fe,queue,mode||"all"); } goBtn.addEventListener("click",function(){ if(sending)return;if(!proxyOk){toast("Upload tph-mailer.php to mu-plugins first");return} if(!contacts.length){toast("Load contacts first");return}if(!subjEl.value.trim()){toast("Add a subject line");return} var bs=clampBatch($("b-size").value);$("b-size").value=bs; var cc=clampConc($("b-conc").value);$("b-conc").value=cc; var queue=remainingContacts(),skipped=contacts.length-queue.length; var msg=skipped?("Continue campaign?\n\n"+queue.length.toLocaleString()+" left to send\n"+skipped.toLocaleString()+" already sent will be skipped"):("Send to "+queue.length.toLocaleString()+" contacts?"); msg+="\n\nSubject: "+subjEl.value.trim()+"\nBatch: "+bs+" | Concurrent batches: "+cc; var attS=attSummary(); msg+=attS?("\n\n⚠ ATTACHED TO EVERY EMAIL: "+attS+"\nThis sends one at a time (much slower). Cancel and remove it under Attachments if it shouldn't be there."):"\n\nNo attachments — fast batch sending."; if(!queue.length){toast("All contacts already sent — use Clear send progress to start over");return} if(!confirm(msg))return;startSend(queue,skipped?"continue":"all"); }); continueBtn.addEventListener("click",function(){ if(sending)return;if(!proxyOk){toast("Upload tph-mailer.php to mu-plugins first");return} var queue=remainingContacts();if(!queue.length){toast("Nothing left to continue");updateContinueUI();return} var attS2=attSummary(); if(!confirm("Continue from where you stopped?\n\n"+queue.length.toLocaleString()+" emails remaining"+(attS2?("\n\n⚠ ATTACHED TO EVERY EMAIL: "+attS2):"")))return; startSend(queue,"continue"); }); retryBtn.addEventListener("click",function(){ if(sending)return;if(!failedList.length){toast("No failed emails to retry");return} if(!confirm("Retry "+failedList.length+" failed emails?"))return;startSend(failedList.slice(),"retry"); }); resetProgBtn.addEventListener("click",function(){ if(sending)return; if(!confirm("Clear send progress for this subject?\n\nNext Launch will email EVERYONE again."))return; try{localStorage.removeItem(campaignKey())}catch(e){} sentSet={};failedList=[];saveDraft();updateContinueUI();toast("Send progress cleared"); }); stopBtn.addEventListener("click",function(){stopReq=true;stopBtn.textContent="Stopping…"}); async function doSend(key,fn,fe,queue,mode){ sending=true;stopReq=false; goBtn.classList.add("is-s");goBtn.querySelector("span").textContent="Sending…"; if(continueBtn){continueBtn.style.display="none";continueBtn.classList.add("is-s")} stopBtn.classList.add("is-v");stopBtn.textContent="Stop Sending"; pBar.classList.add("is-v");sStEl.classList.add("is-v"); if(mode!=="retry"){logEl.innerHTML="";logEl.classList.remove("has");failedList=[]} updateContinueUI(); var already=0;for(var k in sentSet)if(sentSet[k])already++; var total=queue.length,sent=0,fail=0,done=0,qi=0; var batchSize=clampBatch($("b-size").value);$("b-size").value=batchSize; var workers=clampConc($("b-conc").value);$("b-conc").value=workers; var gap=Math.max(0,parseInt($("b-delay").value)||0); var rt=fReply.value.trim()||fe,from=fn+" <"+fe+">"; var att=attachments.filter(function(a){return a&&a.content}).map(function(a){return{filename:a.filename||a.name,content:a.content}}); var useBatch=proxyBatch&&!att.length; if(att.length){batchSize=1;addLog("Attachments detected ("+attSummary()+") — Resend Batch API does not support attachments. Using single-send.","info")} else if(!proxyBatch){addLog("Proxy missing batch support — upload tph-mailer.php v1.2.0. Falling back to single-send.","info");batchSize=1} var nextFails=[]; var startedAt=Date.now(); var authFail=false; var chunks=[]; for(var i=0;i2&&sent>0){ var rate=(sent/elapsed).toFixed(1); goBtn.querySelector("span").textContent="Sending… "+rate+"/sec"; } } addLog((mode==="continue"?"Continuing":"Starting")+" "+total.toLocaleString()+" | "+(useBatch?"batch API":"single")+" ×"+batchSize+" | concurrent "+workers+" | gap "+gap+"ms | "+chunkTotal+" requests","info"); async function worker(){ while(!stopReq){ var my=chunkI++; if(my>=chunkTotal)return; var batch=chunks[my]; try{ if(useBatch){ var emails=batch.map(function(c){return buildEmailPayload(c,key,from,rt,null)}); var d=await postJson(batchUrl,{api_key:key,emails:emails},2,90000); var results=(d&&d.results)||[]; for(var j=0;j0&&!stopReq)await sleep(gap); } } var pool=[]; for(var w=0;w