馃 distributed transcription service
thistle.dunkirk.sh
1/**
2 * Email service using MailChannels API
3 * Docs: https://api.mailchannels.net/tx/v1/documentation
4 */
5
6interface EmailAddress {
7 email: string;
8 name?: string;
9}
10
11interface EmailContent {
12 type: "text/plain" | "text/html";
13 value: string;
14}
15
16interface SendEmailOptions {
17 to: string | EmailAddress;
18 subject: string;
19 html?: string;
20 text?: string;
21 replyTo?: string;
22}
23
24/**
25 * Send an email via MailChannels
26 */
27export async function sendEmail(options: SendEmailOptions): Promise<void> {
28 // Skip sending emails in test mode
29 if (process.env.NODE_ENV === "test" || process.env.SKIP_EMAILS === "true") {
30 console.log(`[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`);
31 return;
32 }
33
34 const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app";
35 const fromName = process.env.SMTP_FROM_NAME || "Thistle";
36 const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app";
37 // Validated at startup
38 const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY as string;
39 const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY as string;
40
41 // Normalize recipient
42 const recipient =
43 typeof options.to === "string" ? { email: options.to } : options.to;
44
45 // Build content array
46 const content: EmailContent[] = [];
47 if (options.text) {
48 content.push({ type: "text/plain", value: options.text });
49 }
50 if (options.html) {
51 content.push({ type: "text/html", value: options.html });
52 }
53
54 if (content.length === 0) {
55 throw new Error("At least one of 'text' or 'html' must be provided");
56 }
57
58 const payload = {
59 personalizations: [
60 {
61 to: [recipient],
62 ...(options.replyTo && {
63 reply_to: { email: options.replyTo },
64 }),
65 dkim_domain: dkimDomain,
66 dkim_selector: "mailchannels",
67 dkim_private_key: dkimPrivateKey,
68 },
69 ],
70 from: {
71 email: fromEmail,
72 name: fromName,
73 },
74 subject: options.subject,
75 content,
76 };
77
78 const response = await fetch("https://api.mailchannels.net/tx/v1/send", {
79 method: "POST",
80 headers: {
81 "content-type": "application/json",
82 "X-Api-Key": mailchannelsApiKey,
83 },
84 body: JSON.stringify(payload),
85 });
86
87 if (!response.ok) {
88 const errorText = await response.text();
89 throw new Error(
90 `MailChannels API error (${response.status}): ${errorText}`,
91 );
92 }
93
94 console.log(`[Email] Sent "${options.subject}" to ${recipient.email}`);
95}