/** * convex/lib/email.ts * * Reine Helfer für den Outreach-Versand (fetch-basiert, kein Node nötig): * Pflicht-Footer, HTML-Aufbau, MIME-Erzeugung, OAuth-Token-Refresh sowie * Versand über Gmail API und Microsoft Graph. SMTP liegt separat in * outreachNode.ts ("use node" + nodemailer). */ // ---- Pflicht-Footer (Compliance) --------------------------------------------- export type FooterInput = { requiredFields: string[]; // aus MarketConfig.requiredFooterFields fromName: string; fromAddress: string; senderAddress?: string; // ladungsfähige Anschrift (aus Org-Einstellungen) imprintUrl?: string; unsubscribeUrl: string; }; /** Erzeugt den verpflichtenden Absender-/Abmelde-Block (Text + HTML). */ export function buildFooter(f: FooterInput): { text: string; html: string } { const lines: string[] = ["—", f.fromName]; if (f.requiredFields.includes("address") && f.senderAddress) { lines.push(f.senderAddress); } lines.push(f.fromAddress); if (f.requiredFields.includes("imprint_link") && f.imprintUrl) { lines.push(`Impressum: ${f.imprintUrl}`); } lines.push( `Wenn Sie keine weitere Nachricht von mir möchten, können Sie sich hier abmelden: ${f.unsubscribeUrl}`, ); const text = lines.join("\n"); const html = `
` + `
` + lines.slice(1).map(escapeHtml).join("
") + `
`; return { text, html }; } export function buildHtmlEmail(bodyText: string, footerHtml: string): string { const body = escapeHtml(bodyText).replace(/\n/g, "
"); return ( `
${body}${footerHtml}
` ); } function escapeHtml(s: string): string { return s .replace(/&/g, "&").replace(//g, ">") .replace(/"/g, """); } // ---- MIME / Encoding --------------------------------------------------------- function utf8ToBase64Url(input: string): string { const bytes = new TextEncoder().encode(input); let bin = ""; for (const b of bytes) bin += String.fromCharCode(b); return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); } function encodeHeader(value: string): string { // RFC 2047 für nicht-ASCII-Header (z. B. Betreff mit Umlauten) return /[^\x00-\x7F]/.test(value) ? `=?UTF-8?B?${btoa(unescape(encodeURIComponent(value)))}?=` : value; } /** RFC-822-Nachricht als base64url (für Gmail users.messages.send). */ export function buildGmailRaw(args: { fromName: string; fromAddress: string; to: string; subject: string; html: string; }): string { const mime = [ `From: ${encodeHeader(args.fromName)} <${args.fromAddress}>`, `To: ${args.to}`, `Subject: ${encodeHeader(args.subject)}`, "MIME-Version: 1.0", 'Content-Type: text/html; charset="UTF-8"', "Content-Transfer-Encoding: 8bit", "", args.html, ].join("\r\n"); return utf8ToBase64Url(mime); } // ---- OAuth-Token-Refresh ----------------------------------------------------- export async function getGmailAccessToken(refreshToken: string): Promise { const res = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: process.env.GMAIL_CLIENT_ID ?? "", client_secret: process.env.GMAIL_CLIENT_SECRET ?? "", refresh_token: refreshToken, grant_type: "refresh_token", }), }); if (!res.ok) throw new Error(`Gmail-Token ${res.status}`); return (await res.json()).access_token as string; } export async function getMsAccessToken(refreshToken: string): Promise { const res = await fetch( "https://login.microsoftonline.com/common/oauth2/v2.0/token", { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: process.env.MS_CLIENT_ID ?? "", client_secret: process.env.MS_CLIENT_SECRET ?? "", refresh_token: refreshToken, grant_type: "refresh_token", scope: "https://graph.microsoft.com/Mail.Send offline_access", }), }, ); if (!res.ok) throw new Error(`MS-Token ${res.status}`); return (await res.json()).access_token as string; } // ---- Versand (fetch) --------------------------------------------------------- export async function sendViaGmail(accessToken: string, raw: string): Promise { const res = await fetch( "https://gmail.googleapis.com/gmail/v1/users/me/messages/send", { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ raw }), }, ); if (!res.ok) throw new Error(`Gmail send ${res.status}: ${(await res.text()).slice(0, 200)}`); } export async function sendViaGraph(args: { accessToken: string; to: string; subject: string; html: string; }): Promise { const res = await fetch("https://graph.microsoft.com/v1.0/me/sendMail", { method: "POST", headers: { Authorization: `Bearer ${args.accessToken}`, "Content-Type": "application/json", }, body: JSON.stringify({ message: { subject: args.subject, body: { contentType: "HTML", content: args.html }, toRecipients: [{ emailAddress: { address: args.to } }], }, saveToSentItems: true, }), }); if (!res.ok) throw new Error(`Graph send ${res.status}: ${(await res.text()).slice(0, 200)}`); } export function domainOf(email: string): string { return (email.split("@")[1] ?? "").toLowerCase(); }