许多开源发票管理应用程序都是使用 Laravel 构建的。作为一名 Javascript 开发人员,我想为熟悉 React 和 Javascript 的开发人员构建“React 解决方案”。
我在 Node.js 中构建服务时发现的一个问题是没有内置邮件程序。所以,我必须找到第 3 方服务来为我做这件事。在这篇文章中,我将集成Courier来为这个项目https://github.com/fazzaamiarso/invoys发送电子邮件。
由于本文不是您的典型跟进文章(更像是“请坐好,看看我是怎么做的”),因此不必一定要熟悉所有使用的技术。但是,熟悉 Typescript 和 Next.js 将有助于更快地理解。
本博客中的技术:
Typescript :类型安全和自动完成是最好的,对吧?
Next.js :用于构建全栈应用程序的生产就绪框架,即使对于初学者也是如此。
Prisma :一个很棒的 ORM 来处理数据库。我们使用 Prisma 是因为它的类型安全和自动完成功能,为添加了 typescript 的开发人员提供了很好的体验。
Trpc :使我们能够轻松地在 Next.js 客户端和服务器之间构建端到端类型安全。
Courier API:一个很棒的服务/平台来处理我们的通知,例如电子邮件、短信等等。
您可以在此处找到完整的源代码以供参考。
在构建功能之前,让我们定义我们的目标。
让我们转到 Courier Dashboard。默认情况下,它在生产环境中。因为我想测试一下,所以我将通过单击右上角的下拉菜单更改为测试环境。
我们可以稍后将所有模板复制到生产环境中,反之亦然。
现在,我将为我的电子邮件通知创建一个品牌。
我只是要在页眉上添加一个徽标(注意徽标宽度固定为 140 像素),在页脚上添加社交链接。设计器 UI 非常简单,所以这是最终结果。
不要忘记发布更改。
目前,UI 上的发送电子邮件按钮没有任何作用。
我将在src/lib/
中创建一个courier.ts
文件以保留所有与 Courier 相关的代码。此外,我将使用courier node.js 客户端库,它已经将所有 Courier API 端点抽象为函数。
在构建功能之前,让我们在 Courier 的设计器中创建电子邮件通知设计并设置 Gmail 提供商。
在电子邮件设计器页面上,我们将看到创建的品牌已经集成。之后,让我们根据需要的数据设计模板。这是最终结果。
注意带{}
的值变为绿色,这意味着它是一个可以动态插入的变量。我还使用变量设置“查看发票”按钮(或操作)。
在我可以使用该模板之前,我需要通过单击预览选项卡来创建一个测试事件。然后,它会显示一个提示,以 JSON 格式命名事件和设置data
。该数据字段将填充绿色{}
变量的值(也可以从代码中设置数据)。由于这是一个测试事件,我将用任意值填充它。
接下来,我将发布模板以便我可以使用它。然后,转到发送选项卡。它将显示以编程方式发送电子邮件所需的代码,并且data
将填充我创建的先前测试事件。
我会将测试AUTH_TOKEN
复制到.env
文件并将代码片段复制到src/lib/courier.ts
。
const authToken = process.env.COURIER_AUTH_TOKEN; // email to receive all sent notifications in DEVELOPMENT mode const testEmail = process.env.COURIER_TEST_EMAIL; const INVOICE_TEMPLATE_ID = <TEMPLATE_ID>; const courierClient = CourierClient({ authorizationToken: authToken, });
创建一个负责发送电子邮件的sendInvoice
函数。要从代码发送电子邮件,我使用courierClient.send()
函数。
// src/lib/courier.ts export const sendInvoice = async ({ customerName, invoiceNumber, invoiceViewUrl, emailTo, productName, dueDate, }: SendInvoice) => { const recipientEmail = process.env.NODE_ENV === "production" ? emailTo : testEmail; const { requestId } = await courierClient.send({ message: { to: { email: recipientEmail, }, template: INVOICE_TEMPLATE_ID, // Data for courier template designer data: { customerName, invoiceNumber, invoiceViewUrl, productName, dueDate, }, }, }); return requestId };
定义sendInvoice
函数的类型。
// src/lib/courier.ts interface SendInvoice { productName: string; dueDate: string; customerName: string; invoiceNumber: string; invoiceViewUrl: string; emailTo: string; }
现在我可以发送电子邮件了,我将在位于src/server/trpc/router/invoice.ts
sendEmail
trpc 端点中调用它。
请记住,trpc 端点是一个 Next.js API 路由。在这种情况下,
sendEmail
将与调用/api/trpc/sendEmail
路由相同,并在后台fetch
。更多解释https://trpc.io/docs/quickstart 。
// src/server/trpc/router/invoice.ts import { sendInvoice } from '@lib/courier'; import { dayjs } from '@lib/dayjs'; // .....SOMEWHERE BELOW sendEmail: protectedProcedure .input( z.object({ customerName: z.string(), invoiceNumber: z.string(), invoiceViewUrl: z.string(), emailTo: z.string(), invoiceId: z.string(), productName: z.string(), dueDate: z.date(), }) ) .mutation(async ({ input }) => { const invoiceData = { ...input, dueDate: dayjs(input.dueDate).format('D MMMM YYYY'), }; await sendInvoice(invoiceData); }),
对于不熟悉 trpc 的人,我所做的与处理POST
请求相同。让我们分解一下。
通过使用 Zod 验证来定义来自客户端的请求输入的 Trpc 方式。我在这里定义了sendInvoice
函数所需的所有数据。
.input( z.object({ customerName: z.string(), invoiceNumber: z.string(), invoiceViewUrl: z.string(), emailTo: z.string(), invoiceId: z.string(), productName: z.string(), dueDate: z.date(), }) )
POST
请求处理程序(突变)。 // input from before .mutation(async ({ input }) => { const invoiceData = { ...input, // format a date to string with a defined format. dueDate: dayjs(input.dueDate).format('D MMMM YYYY'), // ex.'2 January 2023' }; // send the email await sendInvoice(invoiceData); }),
现在,我可以开始向发送电子邮件按钮添加功能。我将使用trpc.useMutation()
函数,它是tanstack-query's
useMutation 的精简包装器。
让我们添加突变函数。成功响应后,我想在 UI 上发送成功祝酒词。
//src/pages/invoices/[invoiceId]/index.tsx import toast from 'react-hot-toast'; const InvoiceDetail: NextPage = () => { // calling the `sendEmail` trpc endpoint with tanstack-query. const sendEmailMutation = trpc.invoice.sendEmail.useMutation({ onSuccess() { toast.success('Email sent!'); } }); }
我可以将该函数用作内联处理程序,但我想为按钮创建一个新的处理程序。
//src/pages/invoices/[invoiceId]/index.tsx // still inside the InvoiceDetail component const sendInvoiceEmail = () => { const hostUrl = window.location.origin; // prevent a user from spamming when the API call is not done. if (sendEmailMutation.isLoading) return; // send input data to `sendEmail` trpc endpoint sendEmailMutation.mutate({ customerName: invoiceDetail.customer.name, invoiceNumber: `#${invoiceDetail.invoiceNumber}`, invoiceViewUrl: `${hostUrl}/invoices/${invoiceDetail.id}/preview`, emailTo: invoiceDetail.customer.email, invoiceId: invoiceDetail.id, dueDate: invoiceDetail.dueDate, productName: invoiceDetail.name, }); };
现在我可以将处理程序附加到发送电子邮件按钮。
//src/pages/invoices/[invoiceId]/index.tsx <Button variant="primary" onClick={sendInvoiceEmail} isLoading={sendEmailMutation.isLoading}> Send to Email </Button>
这是工作用户界面。
要安排在发票到期日前一天发送的提醒,我将使用Courier 的 Automation API 。
首先,让我们在 Courier 设计器中设计电子邮件模板。由于我之前已经完成了整个过程,因此这是最终结果。
在添加函数之前,定义参数的类型并重构类型。
// src/lib/courier interface CourierBaseData { customerName: string; invoiceNumber: string; invoiceViewUrl: string; emailTo: string; } interface SendInvoice extends CourierBaseData { productName: string; dueDate: string; } interface ScheduleReminder extends CourierBaseData { scheduledDate: Date; invoiceId: string; }
现在,我将scheduleReminder
函数添加到src/lib/courier
//src/pages/invoices/[invoiceId]/index.tsx // check if the development environment is production const __IS_PROD__ = process.env.NODE_ENV === 'production'; const PAYMENT_REMINDER_TEMPLATE_ID = '<TEMPLATE_ID>'; export const scheduleReminder = async ({ scheduledDate, emailTo, invoiceViewUrl, invoiceId, customerName, invoiceNumber, }: ScheduleReminder) => { // delay until a day before due date in production, else 20 seconds after sent for development const delayUntilDate = __IS_PROD__ ? scheduledDate : new Date(Date.now() + SECOND_TO_MS * 20); const recipientEmail = __IS_PROD__ ? emailTo : testEmail; // define the automation steps programmatically const { runId } = await courierClient.automations.invokeAdHocAutomation({ automation: { steps: [ // 1. Set delay for the next steps until given date in ISO string { action: 'delay', until: delayUntilDate.toISOString() }, // 2. Send the email notification. Equivalent to `courierClient.send()` { action: 'send', message: { to: { email: recipientEmail }, template: PAYMENT_REMINDER_TEMPLATE_ID, data: { invoiceViewUrl, customerName, invoiceNumber, }, }, }, ], }, }); return runId; };
要发送提醒,我将在成功sendInvoice
尝试后调用scheduleReminder
。让我们修改sendEmail
trpc 端点。
// src/server/trpc/router/invoice.ts sendEmail: protectedProcedure .input(..) // omitted for brevity .mutation(async ({ input }) => { // multiplier for converting day to milliseconds. const DAY_TO_MS = 1000 * 60 * 60 * 24; // get a day before the due date const scheduledDate = new Date(input.dueDate.getTime() - DAY_TO_MS * 1); const invoiceData = {..}; //omitted for brevity await sendInvoice(invoiceData); //after the invoice is sent, schedule the reminder await scheduleReminder({ ...invoiceData, scheduledDate, }); }
现在,如果我尝试通过电子邮件发送发票,我应该会在 20 秒后收到提醒,因为我在开发环境中。
最后,所有的功能都准备好了。但是,我遇到了一个问题,如果客户在付款提醒的预定日期之前付款怎么办?目前,提醒邮件仍会发送。这不是很好的用户体验,而且可能会让客户感到困惑。值得庆幸的是,Courier 具有自动取消功能。
让我们添加cancelAutomationWorkflow
函数,它可以取消src/lib/courier.ts
中的任何自动化工作流。
export const cancelAutomationWorkflow = async ({ cancelation_token, }: { cancelation_token: string; }) => { const { runId } = await courierClient.automations.invokeAdHocAutomation({ automation: { // define a cancel action, that sends a cancelation_token steps: [{ action: 'cancel', cancelation_token }], }, }); return runId; };
什么是取消令牌?它是一个可以设置到自动化工作流的唯一令牌,因此可以通过发送带有匹配cancelation_token
的cancel
操作来取消它。
将 cancelation_token 添加到scheduleReminder
,我使用发票的 Id 作为标记。
// src/lib/courier.ts export const scheduleReminder = async (..) => { // ...omitted for brevity const { runId } = await courierClient.automations.invokeAdHocAutomation({ automation: { // add cancelation token here cancelation_token: `${invoiceId}-reminder`, steps: [ { action: 'delay', until: delayUntilDate.toISOString() }, // ... omitted for brevity
当发票的状态在updateStatus
trpc 端点中更新为PAID
时,我将调用cancelAutomationWorkflow
。
// src/server/trpc/router/invoice.ts updateStatus: protectedProcedure .input(..) // omitted for brevity .mutation(async ({ ctx, input }) => { const { invoiceId, status } = input; // update an invoice's status in database const updatedInvoice = await ctx.prisma.invoice.update({ where: { id: invoiceId }, data: { status }, }); // cancel payment reminder automation workflow if the status is paid. if (updatedInvoice.status === 'PAID') { //call the cancel workflow to cancel the payment reminder for matching cancelation_token. await cancelAutomationWorkflow({ cancelation_token: `${invoiceId}-reminder`, }); } return updatedStatus; }),
这是工作用户界面。
进行网络请求时的一个重要注意事项是请求失败/错误的可能性。我想通过将错误抛给客户端来处理错误,以便在 UI 中反映出来。
出错时,Courier API 默认会抛出CourierHttpClientError
类型的错误。我还将在src/lib/courier.ts
中将所有函数的返回值与以下格式一致。
// On Success type SuccessResponse = { data: any, error: null } // On Error type ErrorResponse = { data: any, error: string }
现在,我可以通过向src/lib/courier.ts
中的所有函数添加一个try-catch
块来处理错误。
try { // ..function code // modified return example return { data: runId, error: null }; } catch (error) { // make sure it's an error from Courier if (error instanceof CourierHttpClientError) { return { data: error.data, error: error.message }; } else { return { data: null, error: "Something went wrong!" }; } }
让我们看一个关于sendEmail
trpc 端点的处理示例。
// src/server/trpc/router/invoice.ts const { error: sendError } = await sendInvoice(..); if (sendError) throw new TRPCClientError(sendError); const { error: scheduleError } = await scheduleReminder(..); if (scheduleError) throw new TRPCClientError(scheduleError);
现在所有模板都已准备就绪,我将把测试环境中的所有资产复制到生产环境中。这是一个例子。
最后,所有功能都与 Courier 集成。我们已经完成了将 Courier API 集成到 Next.js 应用程序的工作流程。尽管它在 Next.js 和 trpc 中,但工作流程与任何其他技术几乎相同。我希望现在您可以自己将 Courier 集成到您的应用程序中。
立即开始:https: //app.courier.com/signup
我是来自印度尼西亚的全栈 Web 开发人员 Fazza Razaq Amiarso。我也是一个开源爱好者。我喜欢在我的博客上分享我的知识和学习。我偶尔会在空闲时间帮助FrontendMentor上的其他开发人员。
在LinkedIn上与我联系。
🔗快递文件
🔗贡献发票
🔗发票动机
也发布在这里。