mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-17 04:46:05 +00:00
finalize initial app resources/drivers
This commit is contained in:
@@ -1,13 +1,28 @@
|
||||
export type TEmailResponse<Data = unknown> = {
|
||||
success: boolean;
|
||||
data?: Data;
|
||||
};
|
||||
|
||||
export interface IEmailDriver<Data = unknown, Options = object> {
|
||||
send(
|
||||
to: string,
|
||||
subject: string,
|
||||
body: string | { text: string; html: string },
|
||||
options?: Options,
|
||||
): Promise<TEmailResponse<Data>>;
|
||||
): Promise<Data>;
|
||||
}
|
||||
|
||||
import type { BkndConfig } from "bknd";
|
||||
import { resendEmail, memoryCache } from "bknd/core";
|
||||
|
||||
export default {
|
||||
onBuilt: async (app) => {
|
||||
app.server.get("/send-email", async (c) => {
|
||||
if (await app.drivers?.email?.send("test@test.com", "Test", "Test")) {
|
||||
return c.text("success");
|
||||
}
|
||||
return c.text("failed");
|
||||
});
|
||||
},
|
||||
options: {
|
||||
drivers: {
|
||||
email: resendEmail({ apiKey: "..." }),
|
||||
cache: memoryCache(),
|
||||
},
|
||||
},
|
||||
} as const satisfies BkndConfig;
|
||||
|
||||
20
app/src/core/drivers/email/mailchannels.spec.ts
Normal file
20
app/src/core/drivers/email/mailchannels.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { mailchannelsEmail } from "./mailchannels";
|
||||
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
describe.skipIf(ALL_TESTS)("mailchannels", () => {
|
||||
it("should throw on failed", async () => {
|
||||
const driver = mailchannelsEmail({ apiKey: "invalid" } as any);
|
||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should send an email", async () => {
|
||||
const driver = mailchannelsEmail({
|
||||
apiKey: process.env.MAILCHANNELS_API_KEY!,
|
||||
from: { email: "accounts@bknd.io", name: "Dennis Senn" },
|
||||
});
|
||||
const response = await driver.send("ds@bknd.io", "Test", "Test");
|
||||
expect(response).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -94,7 +94,7 @@ export const mailchannelsEmail = (
|
||||
},
|
||||
],
|
||||
},
|
||||
options,
|
||||
options ?? {},
|
||||
);
|
||||
|
||||
const res = await fetch(host, {
|
||||
@@ -103,14 +103,15 @@ export const mailchannelsEmail = (
|
||||
"Content-Type": "application/json",
|
||||
"X-Api-Key": config.apiKey,
|
||||
},
|
||||
body: JSON.stringify({ ...payload, ...options }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = (await res.json()) as MailchannelsEmailResponse;
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as MailchannelsEmailResponse;
|
||||
return { success: true, data };
|
||||
if (data?.results.length === 0 || data.results?.[0]?.status !== "sent") {
|
||||
throw new Error(data.results?.[0]?.reason ?? "Unknown error");
|
||||
}
|
||||
return { success: false };
|
||||
|
||||
return (await res.json()) as MailchannelsEmailResponse;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
21
app/src/core/drivers/email/resend.spec.ts
Normal file
21
app/src/core/drivers/email/resend.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "bun:test";
|
||||
import { resendEmail } from "./resend";
|
||||
|
||||
const ALL_TESTS = !!process.env.ALL_TESTS;
|
||||
|
||||
describe.skipIf(ALL_TESTS)("resend", () => {
|
||||
it.only("should throw on failed", async () => {
|
||||
const driver = resendEmail({ apiKey: "invalid" } as any);
|
||||
expect(driver.send("foo@bar.com", "Test", "Test")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should send an email", async () => {
|
||||
const driver = resendEmail({
|
||||
apiKey: process.env.RESEND_API_KEY!,
|
||||
from: "BKND <help@bknd.io>",
|
||||
});
|
||||
const response = await driver.send("help@bknd.io", "Test", "Test");
|
||||
expect(response).toBeDefined();
|
||||
expect(response.id).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -62,11 +62,11 @@ export const resendEmail = (
|
||||
body: JSON.stringify({ ...payload, ...options }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as ResendEmailResponse;
|
||||
return { success: true, data };
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text());
|
||||
}
|
||||
return { success: false };
|
||||
|
||||
return (await res.json()) as ResendEmailResponse;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export type SesSendOptions = {
|
||||
};
|
||||
|
||||
export type SesEmailResponse = {
|
||||
MessageId?: string;
|
||||
MessageId: string;
|
||||
status: number;
|
||||
body: string;
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export type SesEmailResponse = {
|
||||
export const sesEmail = (
|
||||
config: SesEmailOptions,
|
||||
): IEmailDriver<SesEmailResponse, SesSendOptions> => {
|
||||
const endpoint = `https://email.${config.region}.amazonaws.com/`;
|
||||
const endpoint = `https://email.${config.region}.amazonaws.com/v2/email/outbound-emails`;
|
||||
const from = config.from;
|
||||
const aws = new AwsClient({
|
||||
accessKeyId: config.accessKeyId,
|
||||
@@ -38,52 +38,56 @@ export const sesEmail = (
|
||||
body: string | { text: string; html: string },
|
||||
options?: SesSendOptions,
|
||||
) => {
|
||||
// build SES SendEmail params (x-www-form-urlencoded)
|
||||
const params: Record<string, string> = {
|
||||
Action: "SendEmail",
|
||||
Version: "2010-12-01",
|
||||
Source: from,
|
||||
"Destination.ToAddresses.member.1": to,
|
||||
"Message.Subject.Data": subject,
|
||||
// SES v2 SendEmail JSON payload
|
||||
const payload: any = {
|
||||
FromEmailAddress: from,
|
||||
Destination: {
|
||||
ToAddresses: [to],
|
||||
},
|
||||
Content: {
|
||||
Simple: {
|
||||
Subject: { Data: subject, Charset: "UTF-8" },
|
||||
Body: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
if (typeof body === "string") {
|
||||
params["Message.Body.Html.Data"] = body;
|
||||
payload.Content.Simple.Body.Html = { Data: body, Charset: "UTF-8" };
|
||||
} else {
|
||||
params["Message.Body.Html.Data"] = body.html;
|
||||
params["Message.Body.Text.Data"] = body.text;
|
||||
if (body.html) payload.Content.Simple.Body.Html = { Data: body.html, Charset: "UTF-8" };
|
||||
if (body.text) payload.Content.Simple.Body.Text = { Data: body.text, Charset: "UTF-8" };
|
||||
}
|
||||
if (options?.cc) {
|
||||
options.cc.forEach((cc, i) => {
|
||||
params[`Destination.CcAddresses.member.${i + 1}`] = cc;
|
||||
});
|
||||
if (options?.cc && options.cc.length > 0) {
|
||||
payload.Destination.CcAddresses = options.cc;
|
||||
}
|
||||
if (options?.bcc) {
|
||||
options.bcc.forEach((bcc, i) => {
|
||||
params[`Destination.BccAddresses.member.${i + 1}`] = bcc;
|
||||
});
|
||||
if (options?.bcc && options.bcc.length > 0) {
|
||||
payload.Destination.BccAddresses = options.bcc;
|
||||
}
|
||||
if (options?.replyTo) {
|
||||
options.replyTo.forEach((reply, i) => {
|
||||
params[`ReplyToAddresses.member.${i + 1}`] = reply;
|
||||
});
|
||||
if (options?.replyTo && options.replyTo.length > 0) {
|
||||
payload.ReplyToAddresses = options.replyTo;
|
||||
}
|
||||
const formBody = Object.entries(params)
|
||||
.map(([k, v]) => encodeURIComponent(k) + "=" + encodeURIComponent(v))
|
||||
.join("&");
|
||||
const res = await aws.fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: formBody,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const text = await res.text();
|
||||
// try to extract MessageId from XML response
|
||||
let MessageId: string | undefined = undefined;
|
||||
const match = text.match(/<MessageId>([^<]+)<\/MessageId>/);
|
||||
if (match) MessageId = match[1];
|
||||
return {
|
||||
success: res.ok,
|
||||
data: { MessageId, status: res.status, body: text },
|
||||
};
|
||||
if (!res.ok) {
|
||||
// SES v2 returns JSON error body
|
||||
let errorMsg = text;
|
||||
try {
|
||||
const err = JSON.parse(text);
|
||||
errorMsg = err.message || err.Message || text;
|
||||
} catch {}
|
||||
throw new Error(`SES SendEmail failed: ${errorMsg}`);
|
||||
}
|
||||
// parse MessageId from JSON response
|
||||
let MessageId: string = "";
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
MessageId = data.MessageId;
|
||||
} catch {}
|
||||
return { MessageId, status: res.status, body: text };
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user