174 lines
5.7 KiB
TypeScript
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, "&").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<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();
|
|
}
|