Surface audit generations on dashboard audits
This commit is contained in:
173
v2_elemente/email.ts
Normal file
173
v2_elemente/email.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
Reference in New Issue
Block a user