馃 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(
31 `[Email] SKIPPED (test mode): "${options.subject}" to ${typeof options.to === "string" ? options.to : options.to.email}`,
32 );
33 return;
34 }
35
36 const fromEmail = process.env.SMTP_FROM_EMAIL || "noreply@thistle.app";
37 const fromName = process.env.SMTP_FROM_NAME || "Thistle";
38 const dkimDomain = process.env.DKIM_DOMAIN || "thistle.app";
39 // Validated at startup
40 const dkimPrivateKey = process.env.DKIM_PRIVATE_KEY as string;
41 const mailchannelsApiKey = process.env.MAILCHANNELS_API_KEY as string;
42
43 // Normalize recipient
44 const recipient =
45 typeof options.to === "string" ? { email: options.to } : options.to;
46
47 // Build content array
48 const content: EmailContent[] = [];
49 if (options.text) {
50 content.push({ type: "text/plain", value: options.text });
51 }
52 if (options.html) {
53 content.push({ type: "text/html", value: options.html });
54 }
55
56 if (content.length === 0) {
57 throw new Error("At least one of 'text' or 'html' must be provided");
58 }
59
60 const payload = {
61 personalizations: [
62 {
63 to: [recipient],
64 ...(options.replyTo && {
65 reply_to: { email: options.replyTo },
66 }),
67 dkim_domain: dkimDomain,
68 dkim_selector: "mailchannels",
69 dkim_private_key: dkimPrivateKey,
70 },
71 ],
72 from: {
73 email: fromEmail,
74 name: fromName,
75 },
76 subject: options.subject,
77 content,
78 };
79
80 const response = await fetch("https://api.mailchannels.net/tx/v1/send", {
81 method: "POST",
82 headers: {
83 "content-type": "application/json",
84 "X-Api-Key": mailchannelsApiKey,
85 },
86 body: JSON.stringify(payload),
87 });
88
89 if (!response.ok) {
90 const errorText = await response.text();
91 throw new Error(
92 `MailChannels API error (${response.status}): ${errorText}`,
93 );
94 }
95
96 console.log(`[Email] Sent "${options.subject}" to ${recipient.email}`);
97}