diff --git a/.env.sample b/.env.sample index cd50b52..ff28840 100644 --- a/.env.sample +++ b/.env.sample @@ -8,6 +8,6 @@ POSTGRESQL_DBNAME="budgetzen" POSTGRESQL_PORT=5432 POSTGRESQL_CAFILE="" -POSTMARK_SERVER_API_TOKEN="fake" +BREVO_API_KEY="fake" STRIPE_API_KEY="fake" diff --git a/README.md b/README.md index 8d0832b..4a798b0 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ It's not compatible with Budget Zen v2 ([end-to-end encrypted via Userbase](http Or check the [Development section below](#development). -> **NOTE:** You don't need to have emails (Postmark) and subscriptions (Stripe) setup to have the app work. Those are only used for allowing others to automatically manage their account. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. +> **NOTE:** You don't need to have emails (Brevo) and subscriptions (Stripe) setup to have the app work. Those are only used for allowing others to automatically manage their account. You can simply make any `user.status = 'active'` and `user.subscription.expires_at = new Date('2100-01-01')` to "never" expire, in the database, directly. ## Framework-less diff --git a/crons/check-subscriptions.ts b/crons/check-subscriptions.ts index bd47f00..2895c00 100644 --- a/crons/check-subscriptions.ts +++ b/crons/check-subscriptions.ts @@ -1,6 +1,6 @@ import Database, { sql } from '/lib/interfaces/database.ts'; import { getSubscriptions as getStripeSubscriptions } from '/lib/providers/stripe.ts'; -import { sendSubscriptionExpiredEmail } from '/lib/providers/postmark.ts'; +import { sendSubscriptionExpiredEmail } from '/lib/providers/brevo.ts'; import { updateUser } from '/lib/data-utils.ts'; import { User } from '/lib/types.ts'; diff --git a/lib/providers/brevo.ts b/lib/providers/brevo.ts new file mode 100644 index 0000000..318a693 --- /dev/null +++ b/lib/providers/brevo.ts @@ -0,0 +1,153 @@ +import 'std/dotenv/load.ts'; + +import { helpEmail } from '/lib/utils.ts'; + +const BREVO_API_KEY = Deno.env.get('BREVO_API_KEY') || ''; + +enum BrevoTemplateId { + BUDGETZEN_VERIFY_LOGIN = 8, + BUDGETZEN_VERIFY_UPDATE = 9, + BUDGETZEN_VERIFY_DELETE = 10, + BUDGETZEN_UPDATE_BILLING_EMAIL = 11, + BUDGETZEN_SUBSCRIPTION_EXPIRED = 12, +} + +interface BrevoResponse { + messageId?: string; + code?: string; + message?: string; +} + +function getApiRequestHeaders() { + return { + 'Api-Key': BREVO_API_KEY, + 'Accept': 'application/json; charset=utf-8', + 'Content-Type': 'application/json; charset=utf-8', + }; +} + +interface BrevoRequestBody { + templateId?: number; + params: Record; + to: { email: string; name?: string }[]; + cc?: { email: string; name?: string }[]; + bcc?: { email: string; name?: string }[]; + htmlContent?: string; + textContent?: string; + subject?: string; + replyTo: { email: string; name?: string }; + tags?: string[]; + attachment?: { name: string; content: string; url: string }[]; +} + +async function sendEmailWithTemplate( + to: string, + templateId: BrevoTemplateId, + data: BrevoRequestBody['params'], + attachments: BrevoRequestBody['attachment'] = [], + cc?: string, +) { + const email: BrevoRequestBody = { + templateId, + params: data, + to: [{ email: to }], + replyTo: { email: helpEmail }, + }; + + if (attachments?.length) { + email.attachment = attachments; + } + + if (cc) { + email.cc = [{ email: cc }]; + } + + const brevoResponse = await fetch('https://api.brevo.com/v3/smtp/email', { + method: 'POST', + headers: getApiRequestHeaders(), + body: JSON.stringify(email), + }); + const brevoResult = (await brevoResponse.json()) as BrevoResponse; + + if (brevoResult.code || brevoResult.message) { + console.log(JSON.stringify({ brevoResult }, null, 2)); + throw new Error(`Failed to send email "${templateId}"`); + } +} + +export async function sendVerifyLoginEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_VERIFY_LOGIN, data); +} + +export async function sendVerifyDeleteDataEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + deletionSubject: 'all your data', + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_VERIFY_DELETE, data); +} + +export async function sendVerifyDeleteAccountEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + deletionSubject: 'your account', + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_VERIFY_DELETE, data); +} + +export async function sendVerifyUpdateEmailEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + updateSubject: 'your email', + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_VERIFY_UPDATE, data); +} + +export async function sendVerifyUpdatePasswordEmail( + email: string, + verificationCode: string, +) { + const data = { + verificationCode, + updateSubject: 'your password', + }; + + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_VERIFY_UPDATE, data); +} + +export async function sendUpdateEmailInProviderEmail( + oldEmail: string, + newEmail: string, +) { + const data = { + oldEmail, + newEmail, + }; + + await sendEmailWithTemplate(helpEmail, BrevoTemplateId.BUDGETZEN_UPDATE_BILLING_EMAIL, data); +} + +export async function sendSubscriptionExpiredEmail( + email: string, +) { + await sendEmailWithTemplate(email, BrevoTemplateId.BUDGETZEN_SUBSCRIPTION_EXPIRED, {}); +} diff --git a/lib/providers/postmark.ts b/lib/providers/postmark.ts deleted file mode 100644 index 34b6845..0000000 --- a/lib/providers/postmark.ts +++ /dev/null @@ -1,164 +0,0 @@ -import 'std/dotenv/load.ts'; - -import { helpEmail } from '/lib/utils.ts'; - -const POSTMARK_SERVER_API_TOKEN = Deno.env.get('POSTMARK_SERVER_API_TOKEN') || ''; - -interface PostmarkResponse { - To: string; - SubmittedAt: string; - MessageID: string; - ErrorCode: number; - Message: string; -} - -type TemplateAlias = - | 'verify-login' - | 'verify-delete' - | 'verify-update' - | 'update-billing-email' - | 'subscription-expired'; - -function getApiRequestHeaders() { - return { - 'X-Postmark-Server-Token': POSTMARK_SERVER_API_TOKEN, - 'Accept': 'application/json; charset=utf-8', - 'Content-Type': 'application/json; charset=utf-8', - }; -} - -interface PostmarkEmailWithTemplateRequestBody { - TemplateId?: number; - TemplateAlias: TemplateAlias; - TemplateModel: { - [key: string]: any; - }; - InlineCss?: boolean; - From: string; - To: string; - Cc?: string; - Bcc?: string; - Tag?: string; - ReplyTo?: string; - Headers?: { Name: string; Value: string }[]; - TrackOpens?: boolean; - TrackLinks?: 'None' | 'HtmlAndText' | 'HtmlOnly' | 'TextOnly'; - Attachments?: { Name: string; Content: string; ContentType: string }[]; - Metadata?: { - [key: string]: string; - }; - MessageStream: 'outbound' | 'broadcast'; -} - -async function sendEmailWithTemplate( - to: string, - templateAlias: TemplateAlias, - data: PostmarkEmailWithTemplateRequestBody['TemplateModel'], - attachments: PostmarkEmailWithTemplateRequestBody['Attachments'] = [], - cc?: string, -) { - const email: PostmarkEmailWithTemplateRequestBody = { - From: helpEmail, - To: to, - TemplateAlias: templateAlias, - TemplateModel: data, - MessageStream: 'outbound', - }; - - if (attachments?.length) { - email.Attachments = attachments; - } - - if (cc) { - email.Cc = cc; - } - - const postmarkResponse = await fetch('https://api.postmarkapp.com/email/withTemplate', { - method: 'POST', - headers: getApiRequestHeaders(), - body: JSON.stringify(email), - }); - const postmarkResult = (await postmarkResponse.json()) as PostmarkResponse; - - if (postmarkResult.ErrorCode !== 0 || postmarkResult.Message !== 'OK') { - console.log(JSON.stringify({ postmarkResult }, null, 2)); - throw new Error(`Failed to send email "${templateAlias}"`); - } -} - -export async function sendVerifyLoginEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - }; - - await sendEmailWithTemplate(email, 'verify-login', data); -} - -export async function sendVerifyDeleteDataEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - deletionSubject: 'all your data', - }; - - await sendEmailWithTemplate(email, 'verify-delete', data); -} - -export async function sendVerifyDeleteAccountEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - deletionSubject: 'your account', - }; - - await sendEmailWithTemplate(email, 'verify-delete', data); -} - -export async function sendVerifyUpdateEmailEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - updateSubject: 'your email', - }; - - await sendEmailWithTemplate(email, 'verify-update', data); -} - -export async function sendVerifyUpdatePasswordEmail( - email: string, - verificationCode: string, -) { - const data = { - verificationCode, - updateSubject: 'your password', - }; - - await sendEmailWithTemplate(email, 'verify-update', data); -} - -export async function sendUpdateEmailInProviderEmail( - oldEmail: string, - newEmail: string, -) { - const data = { - oldEmail, - newEmail, - }; - - await sendEmailWithTemplate(helpEmail, 'update-billing-email', data); -} - -export async function sendSubscriptionExpiredEmail( - email: string, -) { - await sendEmailWithTemplate(email, 'subscription-expired', {}); -} diff --git a/pages/api/data.ts b/pages/api/data.ts index a6c2de1..77eb90a 100644 --- a/pages/api/data.ts +++ b/pages/api/data.ts @@ -6,7 +6,7 @@ import { validateUserAndSession, validateVerificationCode, } from '/lib/data-utils.ts'; -import { sendVerifyDeleteDataEmail } from '/lib/providers/postmark.ts'; +import { sendVerifyDeleteDataEmail } from '/lib/providers/brevo.ts'; import { Budget, Expense } from '/lib/types.ts'; async function importDataAction(request: Request) { diff --git a/pages/api/session.ts b/pages/api/session.ts index 7365878..789790f 100644 --- a/pages/api/session.ts +++ b/pages/api/session.ts @@ -6,7 +6,7 @@ import { validateUserAndSession, validateVerificationCode, } from '/lib/data-utils.ts'; -import { sendVerifyLoginEmail } from '/lib/providers/postmark.ts'; +import { sendVerifyLoginEmail } from '/lib/providers/brevo.ts'; async function validateSession(request: Request) { const { email }: { email: string } = await request.json(); diff --git a/pages/api/user.ts b/pages/api/user.ts index 077b76a..9928708 100644 --- a/pages/api/user.ts +++ b/pages/api/user.ts @@ -17,7 +17,7 @@ import { sendVerifyDeleteAccountEmail, sendVerifyUpdateEmailEmail, sendVerifyUpdatePasswordEmail, -} from '/lib/providers/postmark.ts'; +} from '/lib/providers/brevo.ts'; import { SupportedCurrencySymbol, validateEmail } from '/public/ts/utils.ts'; import { PAYPAL_CUSTOMER_URL, STRIPE_CUSTOMER_URL } from '/lib/utils.ts'; import { createCustomerPortalSession } from '/lib/providers/stripe.ts';