Files
webdev-pipeline/v2_elemente/email.ts

174 lines
5.7 KiB
TypeScript

/**
* 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 =
`<hr style="border:none;border-top:1px solid #ddd;margin:24px 0"/>` +
`<div style="font-size:12px;color:#666;line-height:1.5">` +
lines.slice(1).map(escapeHtml).join("<br/>") +
`</div>`;
return { text, html };
}
export function buildHtmlEmail(bodyText: string, footerHtml: string): string {
const body = escapeHtml(bodyText).replace(/\n/g, "<br/>");
return (
`<div style="font-family:Arial,Helvetica,sans-serif;font-size:15px;` +
`line-height:1.6;color:#222">${body}${footerHtml}</div>`
);
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
// ---- 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<string> {
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<string> {
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<void> {
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<void> {
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();
}