Created
February 11, 2025 18:46
-
-
Save jessekelly881/e070ff55d23e8219bf58f3c1974b8273 to your computer and use it in GitHub Desktop.
Email service
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { Config, Context, Effect, Option, Schedule, Schema } from "effect"; | |
| import { Resend } from "resend"; | |
| import { EmailRepo } from "../../modules/email/repo"; | |
| import { HashId } from "../hashId"; | |
| import { EmailAddress, Event, OneTimeCode, User } from "@eventmind/domain"; | |
| import type { Mjml2HtmlOptions } from "mjml-react"; | |
| import { EventRepo } from "../../modules/event/repo"; | |
| import { UserRepo } from "../../modules/user/repo"; | |
| import * as EventCancelledEmail from "./emails/eventCancelled"; | |
| import * as EventReminderEmail from "./emails/eventReminder"; | |
| import * as OneTimePasswordEmail from "./emails/oneTimePassword"; | |
| import * as WelcomeEmail from "./emails/welcome"; | |
| interface EmailSendOptions { | |
| to: string; | |
| subject: string; | |
| text: string; | |
| html?: string; | |
| } | |
| export class EmailSendError extends Schema.TaggedError<EmailSendError>()( | |
| "EmailSendError", | |
| { | |
| info: Schema.Struct({ | |
| to: Schema.String, | |
| subject: Schema.String, | |
| }), | |
| message: Schema.String, | |
| }, | |
| ) {} | |
| /** | |
| * Handles the actual sending of emails and records the email in the database. | |
| */ | |
| export class EmailProvider extends Effect.Service<EmailProvider>()( | |
| "app/EmailProvider", | |
| { | |
| scoped: Effect.gen(function* () { | |
| const from = yield* EmailAddress.config("EMAIL_FROM"); | |
| const apiKey = yield* Config.string("RESEND_API_KEY"); | |
| const resend = new Resend(apiKey); | |
| return { | |
| /** | |
| * Sends the email and optionally returns an identifier. | |
| */ | |
| send: (options: EmailSendOptions) => | |
| Effect.promise(() => resend.emails.send({ ...options, from })).pipe( | |
| Effect.flatMap((res) => | |
| res.error | |
| ? Effect.fail(res.error) | |
| : Effect.succeed(Option.fromNullable(res.data?.id)), | |
| ), | |
| // Retry when the server is down. | |
| Effect.retry({ | |
| times: 3, | |
| until: (res) => res.name !== "internal_server_error", | |
| schedule: Schedule.exponential("1 second"), | |
| }), | |
| Effect.mapError( | |
| (res) => | |
| new EmailSendError({ | |
| message: res.message, | |
| info: { to: options.to, subject: options.subject }, | |
| }), | |
| ), | |
| Effect.withSpan("EmailProvider.send", { | |
| attributes: { to: options.to, subject: options.subject }, | |
| }), | |
| ), | |
| }; | |
| }), | |
| }, | |
| ) {} | |
| /** | |
| * Writes the email to the database after sending. | |
| * @category aspect | |
| */ | |
| export const withDatabasePersistence = <A, E, R>( | |
| self: Effect.Effect<A, E, R>, | |
| ) => | |
| Effect.gen(function* () { | |
| const hashId = yield* HashId; | |
| const emailRepo = yield* EmailRepo; | |
| Effect.updateService( | |
| EmailProvider, | |
| (service) => | |
| new EmailProvider({ | |
| ...service, | |
| send: (options: EmailSendOptions) => | |
| Effect.gen(function* () { | |
| const providerIdentifier = yield* service.send(options); | |
| yield* emailRepo | |
| .insert({ | |
| ...options, | |
| recipient: options.to, | |
| providerIdentifier, | |
| }) | |
| .pipe(Effect.provideService(HashId, hashId)); | |
| return providerIdentifier; | |
| }), | |
| }), | |
| )(self); | |
| }); | |
| export class MjmlHtmlOptions extends Context.Reference<MjmlHtmlOptions>()( | |
| "@/MjmlHtmlOptions", | |
| { | |
| defaultValue: () => | |
| ({ | |
| validationLevel: "soft", | |
| minify: true, | |
| }) as Mjml2HtmlOptions, | |
| }, | |
| ) {} | |
| export class Emailer extends Effect.Service<Emailer>()("app/Emailer", { | |
| accessors: true, | |
| dependencies: [EmailProvider.Default, UserRepo.Default, EventRepo.Default], | |
| scoped: Effect.gen(function* () { | |
| const emailProvider = yield* EmailProvider; | |
| const scope = yield* Effect.scope; | |
| const send = (options: EmailSendOptions) => | |
| emailProvider.send(options).pipe( | |
| Effect.tapBoth({ | |
| onFailure: (err) => Effect.logError(err.message), | |
| onSuccess: () => | |
| Effect.logInfo( | |
| `Emailer: Sent - Subject: ${options.subject} | Recipient: ${options.to}`, | |
| ), | |
| }), | |
| Effect.forkIn(scope), | |
| Effect.asVoid, | |
| ); | |
| return { | |
| // event confirmation | |
| // follow up after event | |
| // event suggestions | |
| sendWelcomeEmail: Effect.functionWithSpan({ | |
| options: ({ id: userId }) => ({ | |
| name: "Emailer.sendWelcomeEmail", | |
| attributes: { userId }, | |
| }), | |
| body: (user: User.User) => | |
| Effect.gen(function* () { | |
| yield* send({ | |
| to: user.email, | |
| subject: "Welcome to EventMind", | |
| text: yield* WelcomeEmail.text({ user }), | |
| html: yield* WelcomeEmail.html({ user }), | |
| }); | |
| }), | |
| }), | |
| sendOtcEmail: Effect.functionWithSpan({ | |
| options: (email) => ({ | |
| name: "Emailer.sendOtcEmail", | |
| attributes: { email }, | |
| }), | |
| body: (email: EmailAddress.EmailAddress, otc: OneTimeCode.OneTimeCode) => | |
| Effect.gen(function* () { | |
| yield* send({ | |
| to: email, | |
| subject: "Your one time code", | |
| text: yield* OneTimePasswordEmail.text({ otc }), | |
| html: yield* OneTimePasswordEmail.html({ otc }), | |
| }); | |
| }), | |
| }), | |
| sendEventReminderEmail: Effect.functionWithSpan({ | |
| options: ({ id: userId }, { id: eventId }) => ({ | |
| name: "Emailer.sendEventReminderEmail", | |
| attributes: { userId, eventId }, | |
| }), | |
| body: (user: User.User, event: Event.Event) => | |
| Effect.gen(function* () { | |
| yield* send({ | |
| to: user.email, | |
| subject: event.name, | |
| text: yield* EventReminderEmail.text({ user, event }), | |
| html: yield* EventReminderEmail.html({ user, event }), | |
| }); | |
| }), | |
| }), | |
| sendEventCancelledEmail: Effect.functionWithSpan({ | |
| options: ({ id: userId }, { id: eventId }) => ({ | |
| name: "Emailer.sendEventCancelledEmail", | |
| attributes: { userId, eventId }, | |
| }), | |
| body: (user: User.User, event: Event.Event) => | |
| Effect.gen(function* () { | |
| yield* send({ | |
| to: user.email, | |
| subject: event.name, | |
| text: yield* EventCancelledEmail.text({ user, event }), | |
| html: yield* EventCancelledEmail.html({ user, event }), | |
| }); | |
| }), | |
| }), | |
| }; | |
| }), | |
| }) {} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment