82 lines
3.1 KiB
TypeScript
82 lines
3.1 KiB
TypeScript
/**
|
|
* Outbound messages. Email goes through the app's Resend account (reply-to = merchant);
|
|
* SMS and WhatsApp use the merchant's own Twilio / Meta Cloud API credentials so
|
|
* messaging costs stay on their account.
|
|
*
|
|
* WhatsApp business-initiated messages must use a pre-approved template. Datewise sends
|
|
* the merchant's chosen template with the rendered reminder text as body parameter {{1}}.
|
|
*/
|
|
import { decrypt } from "./crypto.server";
|
|
import type { AppSettings } from "./settings";
|
|
|
|
export type Channel = "email" | "sms" | "whatsapp";
|
|
|
|
export interface OutboundMessage {
|
|
channel: Channel;
|
|
to: string;
|
|
subject?: string;
|
|
body: string;
|
|
}
|
|
|
|
export class NotConfigured extends Error {}
|
|
|
|
async function post(url: string, init: RequestInit) {
|
|
const ctrl = new AbortController();
|
|
const t = setTimeout(() => ctrl.abort(), 8000);
|
|
try {
|
|
const r = await fetch(url, { ...init, signal: ctrl.signal });
|
|
if (!r.ok) throw new Error(`${new URL(url).host} ${r.status}: ${(await r.text()).slice(0, 300)}`);
|
|
return r;
|
|
} finally {
|
|
clearTimeout(t);
|
|
}
|
|
}
|
|
|
|
export async function send(settings: AppSettings, shopName: string, msg: OutboundMessage): Promise<void> {
|
|
if (msg.channel === "email") {
|
|
const key = process.env.RESEND_API_KEY;
|
|
const domain = process.env.EMAIL_FROM_DOMAIN;
|
|
if (!key || !domain) throw new NotConfigured("Email sending is not configured on the server");
|
|
const from = `${settings.messaging.emailFromName || shopName} <reminders@${domain}>`;
|
|
await post("https://api.resend.com/emails", {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
from,
|
|
to: [msg.to],
|
|
reply_to: settings.messaging.replyTo || undefined,
|
|
subject: msg.subject ?? `Your order with ${shopName}`,
|
|
text: msg.body,
|
|
}),
|
|
});
|
|
return;
|
|
}
|
|
if (msg.channel === "sms") {
|
|
const t = settings.messaging.twilio;
|
|
if (!t) throw new NotConfigured("Connect Twilio in Settings to send SMS");
|
|
const auth = Buffer.from(`${t.accountSid}:${decrypt(t.authTokenEnc)}`).toString("base64");
|
|
await post(`https://api.twilio.com/2010-04-01/Accounts/${t.accountSid}/Messages.json`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Basic ${auth}`, "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({ To: msg.to, From: t.from, Body: msg.body }),
|
|
});
|
|
return;
|
|
}
|
|
const w = settings.messaging.whatsapp;
|
|
if (!w) throw new NotConfigured("Connect WhatsApp in Settings to send WhatsApp messages");
|
|
await post(`https://graph.facebook.com/v21.0/${w.phoneNumberId}/messages`, {
|
|
method: "POST",
|
|
headers: { Authorization: `Bearer ${decrypt(w.tokenEnc)}`, "Content-Type": "application/json" },
|
|
body: JSON.stringify({
|
|
messaging_product: "whatsapp",
|
|
to: msg.to.replace(/[^\d]/g, ""),
|
|
type: "template",
|
|
template: {
|
|
name: w.templateName,
|
|
language: { code: w.templateLang },
|
|
components: [{ type: "body", parameters: [{ type: "text", text: msg.body.slice(0, 1000) }] }],
|
|
},
|
|
}),
|
|
});
|
|
}
|