From cd50129ba6f8027b6edfbfd89e5e7120b493f08e Mon Sep 17 00:00:00 2001 From: Nico Schett Date: Sat, 11 Nov 2023 02:41:38 +0100 Subject: [PATCH] feat: implement data logic and functionality --- Dockerfile | 1 - package.json | 7 +- .../20231105104113_initial/migration.sql | 96 ++ .../migration.sql | 40 + .../20231106191208_iam_required/migration.sql | 10 + .../migration.sql | 2 + .../migration.sql | 2 + .../migration.sql | 2 + prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 88 + src/decorators.ts | 26 +- src/email-template-factory.ts | 43 - src/errors/email-factory.errors.ts | 79 + src/init-templates.ts | 336 ---- src/repository/.generated.ts | 416 +++++ src/repository/index.ts | 5 + .../email-factory}/email-engine.ts | 134 +- .../email-factory/email-template-factory.ts | 167 ++ src/services/email-factory/index.ts | 70 + .../email-factory}/resolve-email-address.ts | 40 +- .../email-factory/transformer-sandbox.ts | 134 ++ src/services/email-template.ts | 299 ++++ src/sfi.ts | 100 +- templates/agt-contact-confirmation-email.html | 1307 --------------- templates/agt-contact-email.html | 1317 --------------- templates/agt-order-confirmation-email.html | 1307 --------------- templates/agt-order-email.html | 1317 --------------- ...ns-ballons-contact-confirmation-email.html | 1401 ---------------- templates/ballons-ballons-contact-email.html | 1411 ---------------- ...lons-ballons-order-confirmation-email.html | 1473 ----------------- templates/ballons-ballons-order-email.html | 1417 ---------------- 31 files changed, 1571 insertions(+), 11479 deletions(-) create mode 100644 prisma/migrations/20231105104113_initial/migration.sql create mode 100644 prisma/migrations/20231106181918_make_fields_optional/migration.sql create mode 100644 prisma/migrations/20231106191208_iam_required/migration.sql create mode 100644 prisma/migrations/20231107153805_variable_default_value_not_required/migration.sql create mode 100644 prisma/migrations/20231107231433_add_email_template_description/migration.sql create mode 100644 prisma/migrations/20231107231511_remove_email_template_description_default/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 src/errors/email-factory.errors.ts delete mode 100644 src/init-templates.ts create mode 100644 src/repository/.generated.ts create mode 100644 src/repository/index.ts rename src/{ => services/email-factory}/email-engine.ts (68%) create mode 100644 src/services/email-factory/email-template-factory.ts create mode 100644 src/services/email-factory/index.ts rename src/{ => services/email-factory}/resolve-email-address.ts (77%) create mode 100644 src/services/email-factory/transformer-sandbox.ts create mode 100644 src/services/email-template.ts delete mode 100644 templates/agt-contact-confirmation-email.html delete mode 100644 templates/agt-contact-email.html delete mode 100644 templates/agt-order-confirmation-email.html delete mode 100644 templates/agt-order-email.html delete mode 100644 templates/ballons-ballons-contact-confirmation-email.html delete mode 100644 templates/ballons-ballons-contact-email.html delete mode 100644 templates/ballons-ballons-order-confirmation-email.html delete mode 100644 templates/ballons-ballons-order-email.html diff --git a/Dockerfile b/Dockerfile index c119378..73542ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,6 @@ WORKDIR /app COPY .sf/ ./.sf COPY package.json . -COPY templates/ ./templates # Copy prisma files COPY prisma/schema.prisma ./prisma/schema.prisma diff --git a/package.json b/package.json index 36cfa9c..07df270 100644 --- a/package.json +++ b/package.json @@ -27,15 +27,18 @@ }, "dependencies": { "@devoxa/prisma-relay-cursor-connection": "^3.1.0", + "@netsnek/prisma-repository": "^0.0.2", "@prisma/client": "^5.2.0", "@snek-at/function": "*", "@snek-at/function-cli": "*", "@snek-at/function-server": "*", "@snek-functions/jwt": "*", + "deep-object-diff": "^1.1.9", "dotenv": "^16.0.3", "html-minifier": "^4.0.0", - "twig": "^1.16.0", - "prisma": "^5.2.0" + "isolated-vm": "^4.6.0", + "prisma": "^5.2.0", + "twig": "^1.16.0" }, "devDependencies": { "@types/html-minifier": "^4.0.2", diff --git a/prisma/migrations/20231105104113_initial/migration.sql b/prisma/migrations/20231105104113_initial/migration.sql new file mode 100644 index 0000000..d462c96 --- /dev/null +++ b/prisma/migrations/20231105104113_initial/migration.sql @@ -0,0 +1,96 @@ +-- CreateEnum +CREATE TYPE "EmailAddressType" AS ENUM ('EMAIL_ADDRESS', 'EMAIL_ID', 'USER_ID'); + +-- CreateTable +CREATE TABLE "EmailTemplate" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "content" TEXT NOT NULL, + "verifyReplyTo" BOOLEAN, + "transformer" TEXT, + "authorizationUserId" UUID NOT NULL, + "envelopeId" UUID NOT NULL, + "parentId" UUID, + "createdBy" UUID, + "resourceId" UUID, + "createdAt" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3), + + CONSTRAINT "EmailTemplate_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VariableDefinition" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "name" TEXT NOT NULL, + "description" TEXT, + "defaultValue" TEXT NOT NULL, + "isRequired" BOOLEAN, + "isConstant" BOOLEAN, + "emailTemplateId" UUID, + + CONSTRAINT "VariableDefinition_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "AuthorizationUser" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "userId" UUID NOT NULL, + "authorization" TEXT NOT NULL, + + CONSTRAINT "AuthorizationUser_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmailEnvelope" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "subject" TEXT, + "fromId" UUID NOT NULL, + "replyToId" UUID NOT NULL, + + CONSTRAINT "EmailEnvelope_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EmailAddress" ( + "id" UUID NOT NULL DEFAULT gen_random_uuid(), + "value" TEXT NOT NULL, + "type" "EmailAddressType" NOT NULL, + + CONSTRAINT "EmailAddress_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "_EmailAddressToEmailEnvelope" ( + "A" UUID NOT NULL, + "B" UUID NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "_EmailAddressToEmailEnvelope_AB_unique" ON "_EmailAddressToEmailEnvelope"("A", "B"); + +-- CreateIndex +CREATE INDEX "_EmailAddressToEmailEnvelope_B_index" ON "_EmailAddressToEmailEnvelope"("B"); + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_authorizationUserId_fkey" FOREIGN KEY ("authorizationUserId") REFERENCES "AuthorizationUser"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "EmailEnvelope"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "EmailTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VariableDefinition" ADD CONSTRAINT "VariableDefinition_emailTemplateId_fkey" FOREIGN KEY ("emailTemplateId") REFERENCES "EmailTemplate"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailEnvelope" ADD CONSTRAINT "EmailEnvelope_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "EmailAddress"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailEnvelope" ADD CONSTRAINT "EmailEnvelope_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "EmailAddress"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_EmailAddressToEmailEnvelope" ADD CONSTRAINT "_EmailAddressToEmailEnvelope_A_fkey" FOREIGN KEY ("A") REFERENCES "EmailAddress"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_EmailAddressToEmailEnvelope" ADD CONSTRAINT "_EmailAddressToEmailEnvelope_B_fkey" FOREIGN KEY ("B") REFERENCES "EmailEnvelope"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20231106181918_make_fields_optional/migration.sql b/prisma/migrations/20231106181918_make_fields_optional/migration.sql new file mode 100644 index 0000000..43caf7b --- /dev/null +++ b/prisma/migrations/20231106181918_make_fields_optional/migration.sql @@ -0,0 +1,40 @@ +/* + Warnings: + + - Made the column `createdAt` on table `EmailTemplate` required. This step will fail if there are existing NULL values in that column. + - Made the column `updatedAt` on table `EmailTemplate` required. This step will fail if there are existing NULL values in that column. + +*/ +-- DropForeignKey +ALTER TABLE "EmailEnvelope" DROP CONSTRAINT "EmailEnvelope_fromId_fkey"; + +-- DropForeignKey +ALTER TABLE "EmailEnvelope" DROP CONSTRAINT "EmailEnvelope_replyToId_fkey"; + +-- DropForeignKey +ALTER TABLE "EmailTemplate" DROP CONSTRAINT "EmailTemplate_authorizationUserId_fkey"; + +-- DropForeignKey +ALTER TABLE "EmailTemplate" DROP CONSTRAINT "EmailTemplate_envelopeId_fkey"; + +-- AlterTable +ALTER TABLE "EmailEnvelope" ALTER COLUMN "fromId" DROP NOT NULL, +ALTER COLUMN "replyToId" DROP NOT NULL; + +-- AlterTable +ALTER TABLE "EmailTemplate" ALTER COLUMN "authorizationUserId" DROP NOT NULL, +ALTER COLUMN "envelopeId" DROP NOT NULL, +ALTER COLUMN "createdAt" SET NOT NULL, +ALTER COLUMN "updatedAt" SET NOT NULL; + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_authorizationUserId_fkey" FOREIGN KEY ("authorizationUserId") REFERENCES "AuthorizationUser"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailTemplate" ADD CONSTRAINT "EmailTemplate_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "EmailEnvelope"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailEnvelope" ADD CONSTRAINT "EmailEnvelope_fromId_fkey" FOREIGN KEY ("fromId") REFERENCES "EmailAddress"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EmailEnvelope" ADD CONSTRAINT "EmailEnvelope_replyToId_fkey" FOREIGN KEY ("replyToId") REFERENCES "EmailAddress"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20231106191208_iam_required/migration.sql b/prisma/migrations/20231106191208_iam_required/migration.sql new file mode 100644 index 0000000..9e18927 --- /dev/null +++ b/prisma/migrations/20231106191208_iam_required/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - Made the column `createdBy` on table `EmailTemplate` required. This step will fail if there are existing NULL values in that column. + - Made the column `resourceId` on table `EmailTemplate` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "EmailTemplate" ALTER COLUMN "createdBy" SET NOT NULL, +ALTER COLUMN "resourceId" SET NOT NULL; diff --git a/prisma/migrations/20231107153805_variable_default_value_not_required/migration.sql b/prisma/migrations/20231107153805_variable_default_value_not_required/migration.sql new file mode 100644 index 0000000..f623890 --- /dev/null +++ b/prisma/migrations/20231107153805_variable_default_value_not_required/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "VariableDefinition" ALTER COLUMN "defaultValue" DROP NOT NULL; diff --git a/prisma/migrations/20231107231433_add_email_template_description/migration.sql b/prisma/migrations/20231107231433_add_email_template_description/migration.sql new file mode 100644 index 0000000..087e781 --- /dev/null +++ b/prisma/migrations/20231107231433_add_email_template_description/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EmailTemplate" ADD COLUMN "description" TEXT NOT NULL DEFAULT ''; diff --git a/prisma/migrations/20231107231511_remove_email_template_description_default/migration.sql b/prisma/migrations/20231107231511_remove_email_template_description_default/migration.sql new file mode 100644 index 0000000..65e75aa --- /dev/null +++ b/prisma/migrations/20231107231511_remove_email_template_description_default/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "EmailTemplate" ALTER COLUMN "description" DROP DEFAULT; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..fbffa92 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e69de29..2eca4c0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -0,0 +1,88 @@ +datasource db { + provider = "postgresql" + + url = env("DATABASE_URL") +} + +generator client { + provider = "prisma-client-js" +} + +model EmailTemplate { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + description String + content String + verifyReplyTo Boolean? + transformer String? + + authorizationUser AuthorizationUser? @relation(fields: [authorizationUserId], references: [id]) + authorizationUserId String? @db.Uuid + + envelope EmailEnvelope? @relation(fields: [envelopeId], references: [id]) + envelopeId String? @db.Uuid + + parent EmailTemplate? @relation("EmailTemplateParent", fields: [parentId], references: [id]) + parentId String? @db.Uuid + + linked EmailTemplate[] @relation("EmailTemplateParent") + + variables VariableDefinition[] + + // IAM data + createdBy String @db.Uuid + resourceId String @db.Uuid + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model VariableDefinition { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + name String + description String? + + defaultValue String? + isRequired Boolean? + isConstant Boolean? + EmailTemplate EmailTemplate? @relation(fields: [emailTemplateId], references: [id]) + emailTemplateId String? @db.Uuid +} + +model AuthorizationUser { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + userId String @db.Uuid + // @sf-hide + authorization String + EmailTemplate EmailTemplate[] +} + +model EmailEnvelope { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + subject String? + EmailTemplate EmailTemplate[] + + from EmailAddress? @relation("FromEnvelopes", fields: [fromId], references: [id]) + fromId String? @db.Uuid + + replyTo EmailAddress? @relation("ReplyToEnvelopes", fields: [replyToId], references: [id]) + replyToId String? @db.Uuid + + to EmailAddress[] +} + +model EmailAddress { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + value String + type EmailAddressType + + ToEnvelopes EmailEnvelope[] + FromEnvelopes EmailEnvelope[] @relation("FromEnvelopes") + ReplyToEnvelopes EmailEnvelope[] @relation("ReplyToEnvelopes") +} + +enum EmailAddressType { + EMAIL_ADDRESS + EMAIL_ID + USER_ID +} diff --git a/src/decorators.ts b/src/decorators.ts index f0c91b3..044d273 100644 --- a/src/decorators.ts +++ b/src/decorators.ts @@ -1,5 +1,10 @@ -import { decorator } from "@snek-at/function"; -import { requireAdminForResource } from "@snek-functions/jwt"; +import { Context, decorator } from "@snek-at/function"; +import { + AuthenticationContext, + AuthenticationRequiredError, + requireAdminForResource, + requireAnyAuth, +} from "@snek-functions/jwt"; export const requireAdminOnMailpress = decorator(async (context, args) => { const ctx = await requireAdminForResource(context, [ @@ -8,3 +13,20 @@ export const requireAdminOnMailpress = decorator(async (context, args) => { return ctx; }); + +export const optionalAnyAuth = decorator(async (context, args) => { + let ctx: Context<{ + auth?: AuthenticationContext["auth"]; + multiAuth?: AuthenticationContext["multiAuth"]; + }> = context; + + try { + ctx = await requireAnyAuth(context, args); + } catch (e) { + if (!(e instanceof AuthenticationRequiredError)) { + throw e; + } + } + + return ctx; +}); diff --git a/src/email-template-factory.ts b/src/email-template-factory.ts index a277163..2ed8711 100644 --- a/src/email-template-factory.ts +++ b/src/email-template-factory.ts @@ -79,24 +79,6 @@ export interface TemplateVariableValues { } export class EmailTemplateFactory { - private static templates: TemplateMetadata[] = []; - - private static getTemplateMetadata(id: string): TemplateMetadata { - const metadata = EmailTemplateFactory.templates.find( - (metadata) => metadata.id === id - ); - - if (!metadata) { - throw new TemplateNotFoundError(id); - } - - return metadata; - } - - private static getAllTemplatesMetadata(): TemplateMetadata[] { - return [...EmailTemplateFactory.templates]; - } - private static minifyRenderedTemplate(template: string): string { const result = minify(template, { collapseWhitespace: true, @@ -112,31 +94,6 @@ export class EmailTemplateFactory { return result; } - static createTemplate(id: string, template: EmailTemplate): EmailTemplate { - if (EmailTemplateFactory.templates.some((metadata) => metadata.id === id)) { - throw new TemplateAlreadyExistsError(id); - } - - const metadata: TemplateMetadata = { - id, - template, - }; - - EmailTemplateFactory.templates.push(metadata); - - return template; - } - - static getTemplate(id: string): EmailTemplate { - return EmailTemplateFactory.getTemplateMetadata(id).template; - } - - static getTemplates() { - return EmailTemplateFactory.getAllTemplatesMetadata().map( - (metadata) => metadata.template - ); - } - private static getContext( template: EmailTemplate, values: TemplateVariableValues diff --git a/src/errors/email-factory.errors.ts b/src/errors/email-factory.errors.ts new file mode 100644 index 0000000..2062175 --- /dev/null +++ b/src/errors/email-factory.errors.ts @@ -0,0 +1,79 @@ +import { GraphQLError, GraphQLErrorExtensions } from "graphql"; + +export class TemplateNotFoundError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(templateId: string) { + const message = `No template found with id ${templateId}`; + super(message); + this.extensions = { + statusCode: 404, + code: "TEMPLATE_NOT_FOUND", + description: "No template was found with the given id", + }; + } +} + +export class EnvelopeNotFoundError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(templateId: string) { + const message = `No envelop found for template ${templateId}`; + super(message); + this.extensions = { + statusCode: 404, + code: "ENVELOP_NOT_FOUND", + description: "No envelop was found for the given template", + }; + } +} + +export class TemplateAlreadyExistsError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(templateId: string) { + const message = `Template with id ${templateId} already exists`; + super(message); + this.extensions = { + statusCode: 409, + code: "TEMPLATE_ALREADY_EXISTS", + description: "A template with the given id already exists", + }; + } +} + +export class TemplateVariableValueNotProvidedError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(variableName: string) { + const message = `Value for variable "${variableName}" is required but was not provided`; + super(message); + this.extensions = { + statusCode: 400, + code: "VARIABLE_VALUE_NOT_PROVIDED", + description: `A value for the required variable '${variableName}' was not provided`, + }; + } +} + +export class TemplateVariableIsConstantError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(variableName: string) { + const message = `Variable "${variableName}" is constant and cannot be provided`; + super(message); + this.extensions = { + statusCode: 400, + code: "VARIABLE_IS_CONSTANT", + description: `The variable '${variableName}' is constant and cannot be provided`, + }; + } +} + +export class FromEmailAddressNotAuthorizedError extends GraphQLError { + extensions: GraphQLErrorExtensions; + constructor(from: string) { + const message = `From email address ${from} is not authorized`; + super(message); + this.extensions = { + statusCode: 403, + code: "FROM_EMAIL_ADDRESS_NOT_AUTHORIZED", + description: `The from email address ${from} is not authorized`, + }; + } +} diff --git a/src/init-templates.ts b/src/init-templates.ts deleted file mode 100644 index 54d2042..0000000 --- a/src/init-templates.ts +++ /dev/null @@ -1,336 +0,0 @@ -import fs from "fs"; -import path from "path"; - -import { - EmailTemplateFactory, - EmailAddressType, - EmailTemplate, -} from "./email-template-factory"; - -const authorization = process.env.MAILPRESS_AUTHORIZATION; - -if (!authorization) { - throw new Error("No Authorization"); -} - -const importLocalTemplate = (templatePath: string) => { - const cwd = process.cwd(); - const pathToTemplate = path.resolve(cwd, "templates", templatePath); - return fs.readFileSync(pathToTemplate, "utf8"); -}; - -const ballonsBallonsOrderEmail = (wholesale: boolean) => ({ - content: importLocalTemplate("ballons-ballons-order-email.html"), - envelope: { - from: { - value: "mailpress@snek.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - to: [ - { - value: "office@ballons-ballons.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - ], - subject: "Neue Anfrage auf Ballons & Ballons", - }, - authorizationUser: { - id: "74e7de97-48e5-40e0-bca6-e05daa6e466d", - authorization, - }, - variables: { - cart: { - isRequired: true, - // isConstant: true, - // defaultValue: [ - // { - // name: "Ballon", - // price: 1.5, - // quantity: 1, - // sku: "B-15211", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // { - // name: "Ballon 2", - // price: 62, - // quantity: 4, - // sku: "B-1521A", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // { - // name: "Ballon 4", - // price: 23, - // quantity: 7, - // sku: "B-15213", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // ], - }, - order: { - isRequired: true, - // isConstant: true, - // defaultValue: { - // id: "123456789", - // totalPrice: 123.45, - // currency: "EUR", - // note: "Bitte an der Haustür abstellen", - // }, - }, - customer: { - isRequired: true, - // isConstant: true, - // defaultValue: { - // emailAddress: "schett@snek.at", - // firstName: "Nico", - // lastName: "Schett", - // phone: "+43 000 111 222 333" - // }, - }, - wholesale: { - isRequired: true, - // isConstant: true, - // defaultValue: wholesale, - } - }, - verifyReplyTo: wholesale, - linkedEmailTemplates: [ - { - content: importLocalTemplate( - "ballons-ballons-order-confirmation-email.html" - ), - variables: { - cart: { - isRequired: true, - // isConstant: true, - // defaultValue: [ - // { - // name: "Ballon", - // price: 1.5, - // quantity: 1, - // sku: "B-15211", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // { - // name: "Ballon 2", - // price: 62, - // quantity: 4, - // sku: "B-1521A", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // { - // name: "Ballon 4", - // price: 23, - // quantity: 7, - // sku: "B-15213", - // imgSrc: - // "https://w7.pngwing.com/pngs/904/855/png-transparent-balloon-red-ballons-blue-heart-color-thumbnail.png", - // }, - // ], - }, - order: { - isRequired: true, - // isConstant: true, - // defaultValue: { - // id: "123456789", - // totalPrice: 123.45, - // currency: "EUR", - // note: "Bitte an der Haustür abstellen", - // }, - }, - customer: { - isRequired: true, - // isConstant: true, - // defaultValue: { - // emailAddress: "schett@snek.at", - // firstName: "Nico", - // lastName: "Schett", - // phone: "+43 000 111 222 333" - // }, - }, - wholesale: { - isRequired: true, - // isConstant: true, - // defaultValue: wholesale, - } - }, - envelope: { - subject: "Ihre Anfrage auf Ballons & Ballons", - }, - $transformer: ({ envelope }) => { - console.log("Parent envelope", envelope); - - return { - envelope: { - to: envelope.replyTo ? [envelope.replyTo] : [], - replyTo: undefined, - }, - }; - }, - }, - ], -}) - -EmailTemplateFactory.createTemplate("BALLOONS_CONTACT_EMAIL", { - content: importLocalTemplate("ballons-ballons-contact-email.html"), - variables: { - firstName: { isRequired: true }, - lastName: { isRequired: true }, - email: { isRequired: true }, - phone: { isRequired: false }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - envelope: { - from: { - value: "mailpress@snek.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - to: [ - { - value: "office@ballons-ballons.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - ], - subject: "Neue Kontaktanfrage", - }, - authorizationUser: { - id: "74e7de97-48e5-40e0-bca6-e05daa6e466d", - authorization, - }, - linkedEmailTemplates: [ - { - content: importLocalTemplate( - "ballons-ballons-contact-confirmation-email.html" - ), - variables: { - firstName: { isRequired: true }, - lastName: { isRequired: true }, - email: { isRequired: true }, - phone: { isRequired: false }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - $transformer: ({ envelope }) => { - console.log("parentTemplateenvelope", envelope); - - if (envelope) { - envelope.to = envelope.replyTo ? [envelope.replyTo] : []; - envelope.replyTo = undefined; - } - - return { - envelope, - }; - }, - }, - ], -}); - -EmailTemplateFactory.createTemplate("BALLOONS_ORDER_EMAIL", ballonsBallonsOrderEmail(false)); - -EmailTemplateFactory.createTemplate("BALLOONS_ORDER_WHOLESALE_EMAIL", ballonsBallonsOrderEmail(true)); - -EmailTemplateFactory.createTemplate("AGT_CONTACT_MAIL", { - content: importLocalTemplate("agt-contact-email.html"), - variables: { - name: { isRequired: true }, - email: { isRequired: true }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - envelope: { - from: { - value: "mailpress@snek.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - to: [ - { - value: "info@agt-guntrade.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - ], - subject: "Neue Kontaktanfrage", - }, - authorizationUser: { - id: "74e7de97-48e5-40e0-bca6-e05daa6e466d", - authorization, - }, - linkedEmailTemplates: [ - { - content: importLocalTemplate("agt-contact-confirmation-email.html"), - variables: { - name: { isRequired: true }, - email: { isRequired: true }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - $transformer: ({ envelope }) => { - console.log("parentTemplateenvelope", envelope); - - if (envelope) { - envelope.to = envelope.replyTo ? [envelope.replyTo] : []; - envelope.replyTo = undefined; - } - - return { - envelope, - }; - }, - }, - ], -}); - -EmailTemplateFactory.createTemplate("AGT_ORDER_MAIL", { - content: importLocalTemplate("agt-order-email.html"), - variables: { - name: { isRequired: true }, - email: { isRequired: true }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - envelope: { - from: { - value: "mailpress@snek.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - to: [ - { - value: "info@agt-guntrade.at", - type: EmailAddressType.EMAIL_ADDRESS, - }, - ], - subject: "Neue Bestellung auf AGT Gun Trade", - }, - authorizationUser: { - id: "74e7de97-48e5-40e0-bca6-e05daa6e466d", - authorization, - }, - linkedEmailTemplates: [ - { - content: importLocalTemplate("agt-order-confirmation-email.html"), - variables: { - name: { isRequired: true }, - email: { isRequired: true }, - message: { isRequired: true }, - invokedOnUrl: { isRequired: true }, - }, - $transformer: ({ envelope }) => { - console.log("parentTemplateenvelope", envelope); - - if (envelope) { - envelope.to = envelope.replyTo ? [envelope.replyTo] : []; - envelope.replyTo = undefined; - } - - return { - envelope, - }; - }, - }, - ], -}); diff --git a/src/repository/.generated.ts b/src/repository/.generated.ts new file mode 100644 index 0000000..ec90092 --- /dev/null +++ b/src/repository/.generated.ts @@ -0,0 +1,416 @@ +// @ts-ignore +import type {$Enums} from "@prisma/client"; +import {PrismaClient} from "@prisma/client"; +import { Prisma } from "@prisma/client"; +import { + ConnectionArguments, + findManyCursorConnection, +} from "@devoxa/prisma-relay-cursor-connection"; + +import _Repository from './index.js' + +type AsyncFn = Args extends [] + ? () => Promise + : (...args: Args) => Promise + +const client = new PrismaClient() + + +export class ObjectManager< + T extends keyof Prisma.TypeMap["model"], + Cls extends new (fields: any) => InstanceType +> { + constructor(private instance: any, private model: Cls) {} + + get = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["findFirst"]["args"] + ): Promise> => { + const obj = await this.instance.findFirst(args); + + if (!obj) { + throw new Error("Object not found"); + } + + const i = new this.model(obj); + + return i; + }; + + filter = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["findMany"]["args"] + ): Promise[]> => { + const objs = await this.instance.findMany(args); + + return objs.map((obj: any) => new this.model(obj)) as InstanceType[]; + }; + + paginate = async ( + connectionArguments?: ConnectionArguments, + args?: Prisma.TypeMap["model"][T]["operations"]["findMany"]["args"] + ) => { + return findManyCursorConnection( + async (connectionArgs) => { + const objs = await this.instance.findMany({ + ...args, + ...connectionArgs, + }); + + return objs.map( + (obj: any) => new this.model(obj) + ) as InstanceType[]; + }, + () => this.count(args as any), + connectionArguments + ); + }; + + create = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["create"]["args"] + ): Promise> => { + const obj = await this.instance.create(args); + + return new this.model(obj); + }; + + update = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["update"]["args"] + ): Promise> => { + const obj = await this.instance.update(args); + + return new this.model(obj); + }; + + delete = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["delete"]["args"] + ): Promise> => { + const obj = await this.instance.delete(args); + + return new this.model(obj); + }; + + upsert = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["upsert"]["args"] + ): Promise> => { + const obj = await this.instance.upsert(args); + + return new this.model(obj); + }; + + count = async ( + args?: Prisma.TypeMap["model"][T]["operations"]["count"]["args"] + ): Promise => { + return await this.instance.count(args); + }; +} + + +abstract class Model { + $save() {} + $fetch() {} + + constructor() { + + } + + $boostrap(that: any, fields: any, hiddenFields: string[]) { + for (const [key, value] of Object.entries(fields)) { + const keyName = hiddenFields.includes(key) ? "$" + key : key; + + that[keyName as keyof this] = value as any; + } + } +} + + export class EmailTemplate extends Model { + + static objects = new ObjectManager<"EmailTemplate", typeof EmailTemplate>( + client.emailTemplate, + EmailTemplate + ); + + + + constructor(data: Prisma.EmailTemplateCreateInput) { + super(); + + const hiddenFields: string[] = ["authorizationUserId","envelopeId","parentId"]; + + this.$boostrap(this, data, hiddenFields); + } + + + + id!: string; +description!: string; +content!: string; +verifyReplyTo!: boolean | null; +transformer!: string | null; +authorizationUser: AsyncFn | null> = async () => { + if (!this.$authorizationUserId) return null; + + + + return _Repository.AuthorizationUser.objects.get({ + where: { + id:this.$authorizationUserId, + }, + }); + }; +$authorizationUserId!: string | null; +envelope: AsyncFn | null> = async () => { + if (!this.$envelopeId) return null; + + + + return _Repository.EmailEnvelope.objects.get({ + where: { + id:this.$envelopeId, + }, + }); + }; +$envelopeId!: string | null; +parent: AsyncFn | null> = async () => { + if (!this.$parentId) return null; + + + + return _Repository.EmailTemplate.objects.get({ + where: { + id:this.$parentId, + }, + }); + }; +$parentId!: string | null; +linked: AsyncFn[]> = async () => { + + + + + return _Repository.EmailTemplate.objects.filter({ + where: { + parentId:this.id, + }, + }); + }; +variables: AsyncFn[]> = async () => { + + + + + return _Repository.VariableDefinition.objects.filter({ + where: { + emailTemplateId:this.id, + }, + }); + }; +createdBy!: string; +resourceId!: string; +createdAt!: Date; +updatedAt!: Date; + + } + +export class VariableDefinition extends Model { + + static objects = new ObjectManager<"VariableDefinition", typeof VariableDefinition>( + client.variableDefinition, + VariableDefinition + ); + + + + constructor(data: Prisma.VariableDefinitionCreateInput) { + super(); + + const hiddenFields: string[] = ["emailTemplateId"]; + + this.$boostrap(this, data, hiddenFields); + } + + + + id!: string; +name!: string; +description!: string | null; +defaultValue!: string | null; +isRequired!: boolean | null; +isConstant!: boolean | null; +EmailTemplate: AsyncFn | null> = async () => { + if (!this.$emailTemplateId) return null; + + + + return _Repository.EmailTemplate.objects.get({ + where: { + id:this.$emailTemplateId, + }, + }); + }; +$emailTemplateId!: string | null; + + } + +export class AuthorizationUser extends Model { + + static objects = new ObjectManager<"AuthorizationUser", typeof AuthorizationUser>( + client.authorizationUser, + AuthorizationUser + ); + + + + constructor(data: Prisma.AuthorizationUserCreateInput) { + super(); + + const hiddenFields: string[] = []; + + this.$boostrap(this, data, hiddenFields); + } + + + + id!: string; +userId!: string; +authorization!: string; +EmailTemplate: AsyncFn[]> = async () => { + + + + + return _Repository.EmailTemplate.objects.filter({ + where: { + authorizationUserId:this.id, + }, + }); + }; + + } + +export class EmailEnvelope extends Model { + + static objects = new ObjectManager<"EmailEnvelope", typeof EmailEnvelope>( + client.emailEnvelope, + EmailEnvelope + ); + + + + constructor(data: Prisma.EmailEnvelopeCreateInput) { + super(); + + const hiddenFields: string[] = ["fromId","replyToId"]; + + this.$boostrap(this, data, hiddenFields); + } + + + + id!: string; +subject!: string | null; +EmailTemplate: AsyncFn[]> = async () => { + + + + + return _Repository.EmailTemplate.objects.filter({ + where: { + envelopeId:this.id, + }, + }); + }; +from: AsyncFn | null> = async () => { + if (!this.$fromId) return null; + + + + return _Repository.EmailAddress.objects.get({ + where: { + id:this.$fromId, + }, + }); + }; +$fromId!: string | null; +replyTo: AsyncFn | null> = async () => { + if (!this.$replyToId) return null; + + + + return _Repository.EmailAddress.objects.get({ + where: { + id:this.$replyToId, + }, + }); + }; +$replyToId!: string | null; +to: AsyncFn[]> = async () => { + + + + + return _Repository.EmailAddress.objects.filter({ + where: { + ToEnvelopes:{some:{id:this.id}}, + }, + }); + }; + + } + +export class EmailAddress extends Model { + + static objects = new ObjectManager<"EmailAddress", typeof EmailAddress>( + client.emailAddress, + EmailAddress + ); + + + + constructor(data: Prisma.EmailAddressCreateInput) { + super(); + + const hiddenFields: string[] = []; + + this.$boostrap(this, data, hiddenFields); + } + + + + id!: string; +value!: string; +type!: $Enums.EmailAddressType; +ToEnvelopes: AsyncFn[]> = async () => { + + + + + return _Repository.EmailEnvelope.objects.filter({ + where: { + to:{some:{id:this.id}}, + }, + }); + }; +FromEnvelopes: AsyncFn[]> = async () => { + + + + + return _Repository.EmailEnvelope.objects.filter({ + where: { + fromId:this.id, + }, + }); + }; +ReplyToEnvelopes: AsyncFn[]> = async () => { + + + + + return _Repository.EmailEnvelope.objects.filter({ + where: { + replyToId:this.id, + }, + }); + }; + + } + diff --git a/src/repository/index.ts b/src/repository/index.ts new file mode 100644 index 0000000..06ab127 --- /dev/null +++ b/src/repository/index.ts @@ -0,0 +1,5 @@ +import * as Repositories from "./.generated"; + +export default { + ...Repositories, +}; diff --git a/src/email-engine.ts b/src/services/email-factory/email-engine.ts similarity index 68% rename from src/email-engine.ts rename to src/services/email-factory/email-engine.ts index 9d9c689..1890430 100644 --- a/src/email-engine.ts +++ b/src/services/email-factory/email-engine.ts @@ -1,7 +1,6 @@ import { GraphQLError } from "graphql"; import { - EmailAddressType, EmailEnvelope, EmailTemplate, EmailTemplateFactory, @@ -13,14 +12,18 @@ import { verifyReplyToEmailAddress, } from "./resolve-email-address"; -import { sq } from "./clients/mailer/src/index.js"; +import { sq } from "../../clients/mailer/src/index.js"; + +import repository from "../../repository"; +import { executeInSandbox } from "./transformer-sandbox.js"; +import { AuthorizationUser } from "../../repository/.generated.js"; interface MailServiceSendMailOptions { envelope: EmailEnvelope; bodyHTML?: string; body: string; authorizationUser: { - id: string; + userId: string; authorization: string; }; } @@ -53,8 +56,8 @@ class MailerMailerService implements MailerService { } else { resolvedFrom = await resolveFromEmailAddress( { - type: EmailAddressType.USER_ID, - value: authorizationUser.id, + type: "USER_ID", + value: authorizationUser.userId, }, authorizationUser ); @@ -159,23 +162,20 @@ class MailerMailerService implements MailerService { } export class EmailEngine { - template?: EmailTemplate; - parentTemplate?: EmailTemplate; + template?: InstanceType; authorizationUser?: { - id: string; + userId: string; authorization: string; }; constructor(options: { - template?: EmailTemplate; - parentTemplate?: EmailTemplate; + template?: InstanceType; authorizationUser?: { - id: string; + userId: string; authorization: string; }; }) { this.template = options.template; - this.parentTemplate = options.parentTemplate; this.authorizationUser = options.authorizationUser; } @@ -191,41 +191,88 @@ export class EmailEngine { values?: Record; }) { if (this.template) { - let emailTemplate = this.template; + const templateEnvelope = await this.template.envelope(); - if (emailTemplate.$transformer) { - if (!this.parentTemplate) { - throw new GraphQLError( - "No parent template provided. This is required for linked email templates" - ); + if (templateEnvelope) { + if (templateEnvelope.subject) { + envelope.subject = templateEnvelope.subject; + } + const from = await templateEnvelope.from(); + if (from) { + envelope.from = from; } + const to = await templateEnvelope.to(); + if (to.length > 0) { + envelope.to = to; + } + const replyTo = await templateEnvelope.replyTo(); + if (replyTo) { + envelope.replyTo = replyTo; + } + } - const transformedTemplate = emailTemplate.$transformer({ - envelope, + if (this.template.transformer) { + const parentTemplate = await this.template.parent(); + + // transformer is a stringified function that is evaluated + // and returns a transformed email template + + const transformedTemplate = await executeInSandbox({ + input: { + envelope, + values: values || {}, + body, + bodyHTML, + }, + template: this.template, + parentTemplate, }); if (transformedTemplate) { - // Deep-Merge the transformed template with the email template - emailTemplate = { - ...emailTemplate, - ...transformedTemplate, - envelope: { - ...emailTemplate.envelope, + if (transformedTemplate.verifyReplyTo !== undefined) { + this.template.verifyReplyTo = transformedTemplate.verifyReplyTo; + } + + if (transformedTemplate.envelope) { + envelope = { + ...envelope, ...transformedTemplate.envelope, - }, - }; + }; + } } } - bodyHTML = EmailTemplateFactory.render(this.template, values); - body = ""; + const variables = await this.template.variables(); + + console.log( + "variables", + Object.values(variables).reduce( + (acc, variable) => ({ + ...acc, + [variable.name]: variable, + }), + {} + ), + values + ); + + bodyHTML = EmailTemplateFactory.render( + { + content: this.template.content, + variables: Object.values(variables).reduce( + (acc, variable) => ({ + ...acc, + [variable.name]: variable, + }), + {} + ), + }, + values + ); - envelope = { - ...envelope, - ...emailTemplate.envelope, - }; + body = ""; - if (emailTemplate.verifyReplyTo) { + if (this.template.verifyReplyTo) { if (envelope.replyTo && this.authorizationUser) { await verifyReplyToEmailAddress( envelope.replyTo, @@ -239,10 +286,14 @@ export class EmailEngine { } } - if (emailTemplate.envelope?.from) { - if (emailTemplate.authorizationUser) { - this.authorizationUser = emailTemplate.authorizationUser; - } + // const emailTemplateEnvelope = await emailTemplate.envelope(); + + // const from = await emailTemplateEnvelope?.from(); + + const authorizationUser = await this.template.authorizationUser(); + + if (authorizationUser) { + this.authorizationUser = authorizationUser; } } @@ -259,13 +310,12 @@ export class EmailEngine { authorizationUser: this.authorizationUser, }); - if (this.template?.linkedEmailTemplates) { - const linkedEmailTemplates = this.template.linkedEmailTemplates; + const linkedEmailTemplates = await this.template?.linked(); + if (linkedEmailTemplates) { for (const linkedEmailTemplate of linkedEmailTemplates) { let linkedEmailEngine = new EmailEngine({ template: linkedEmailTemplate, - parentTemplate: this.template, authorizationUser: this.authorizationUser, }); diff --git a/src/services/email-factory/email-template-factory.ts b/src/services/email-factory/email-template-factory.ts new file mode 100644 index 0000000..9ef11bd --- /dev/null +++ b/src/services/email-factory/email-template-factory.ts @@ -0,0 +1,167 @@ +import Twig from "twig"; +import { minify } from "html-minifier"; + +Twig.extendFilter("format_currency", (value: number, params: false | any[]) => { + return new Intl.NumberFormat("de-AT", { + style: "currency", + currency: params ? params[0] : "EUR", + }).format(value); +}); + +import { + TemplateAlreadyExistsError, + TemplateNotFoundError, + TemplateVariableValueNotProvidedError, + EnvelopeNotFoundError, + FromEmailAddressNotAuthorizedError, + TemplateVariableIsConstantError, +} from "../../errors/email-factory.errors"; + +export interface TemplateMetadata { + id: string; + template: EmailTemplate; +} + +export interface EmailTemplate { + content: string; + variables?: TemplateVariables; + envelope?: EmailEnvelope; + /** + * If true, the replyTo address of the email will be verified against the + * authorized email addresses of the user that is sending the email. + * + * If the replyTo address is not authorized, an error will be thrown. + * + * If false, the replyTo address will not be verified. + * This is useful for e.g. contact forms where the email address of the sender + * is not known. A replyTo address is still required, but it can be any email + * address. + */ + verifyReplyTo?: boolean; + authorizationUser?: { + id: string; + authorization: string; + }; + linkedEmailTemplates?: EmailTemplate[]; + $transformer?: (context: { envelope: EmailEnvelope }) => any; +} + +interface TemplateVariables { + [variableName: string]: VariableDefinition; +} + +export interface EmailEnvelope { + from?: EmailAddress | null; + to?: EmailAddress[] | null; + subject?: string | null; + replyTo?: EmailAddress | null; +} + +export interface EmailAddress { + value: string; + type: "EMAIL_ADDRESS" | "EMAIL_ID" | "USER_ID"; +} + +interface VariableDefinition { + defaultValue?: any; + isRequired?: boolean; + isConstant?: boolean; +} + +export interface TemplateVariableValues { + [variableName: string]: any; +} + +export class EmailTemplateFactory { + private static minifyRenderedTemplate(template: string): string { + const result = minify(template, { + collapseWhitespace: true, + removeComments: true, + removeEmptyAttributes: true, + removeEmptyElements: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true, + }); + + return result; + } + + private static getContext( + variables: TemplateVariables, + values: TemplateVariableValues + ): any { + const context: any = {}; + + for (const variableName in variables) { + if (variables.hasOwnProperty(variableName)) { + const variable = variables[variableName]; + + // Check if variable is a constant and has a value provided + if (variable.isConstant && variableName in values) { + throw new TemplateVariableIsConstantError(variableName); + } + + // Check if variable is required and has no value provided + if ( + !variable.isConstant && + !(variableName in values) && + variable.isRequired + ) { + throw new TemplateVariableValueNotProvidedError(variableName); + } + + // Set the context variable to the provided value or default value + context[variableName] = + values[variableName] || variable.defaultValue || null; + } + } + + // Add default variables + // context.currentTime = () => new Date().toLocaleString(); + + return context; + } + + private static renderTemplate( + template: { + content: string; + variables: TemplateVariables; + }, + values: TemplateVariableValues = {} + ): string { + const twigTemplate = Twig.twig({ data: template.content }); + + const context = EmailTemplateFactory.getContext(template.variables, values); + + const result = twigTemplate.render(context); + + return EmailTemplateFactory.minifyRenderedTemplate(result); + } + + templateId: string; + emailTemplate: EmailTemplate; + + static render( + template: { + content: string; + variables: TemplateVariables; + }, + values: TemplateVariableValues = {} + ): string { + try { + return EmailTemplateFactory.renderTemplate(template, values); + } catch (error) { + if ( + error instanceof TemplateNotFoundError || + error instanceof EnvelopeNotFoundError || + error instanceof TemplateVariableValueNotProvidedError + ) { + throw error; + } else { + throw new Error(`Failed to render template: ${error}`); + } + } + } +} diff --git a/src/services/email-factory/index.ts b/src/services/email-factory/index.ts new file mode 100644 index 0000000..c1a37fb --- /dev/null +++ b/src/services/email-factory/index.ts @@ -0,0 +1,70 @@ +import { $Enums } from "@prisma/client"; +import { withContext } from "@snek-at/function"; + +import repository from "../../repository"; +import { EmailEngine } from "./email-engine"; +import { optionalAnyAuth } from "../../decorators"; + +export class EmailFactoryService { + mailSchedule = withContext( + (context) => + async ( + envelope: { + subject?: string; + to?: Array<{ + value: string; + type: $Enums.EmailAddressType; + }>; + + from?: { + value: string; + type: $Enums.EmailAddressType; + }; + replyTo?: { + value: string; + type: $Enums.EmailAddressType; + }; + }, + body?: string, + bodyHTML?: string, + template?: { + id: string; + values?: { + [variableName: string]: any; + }; + } + ) => { + const originUserId = context.multiAuth?.[0].userId; + + const emailTemplate = template?.id + ? await repository.EmailTemplate.objects.get({ + where: { + id: template.id, + }, + }) + : undefined; + + const engine = new EmailEngine({ + template: emailTemplate, + authorizationUser: originUserId + ? { + userId: originUserId, + authorization: context.req.headers.authorization!, + } + : undefined, + }); + + return await engine.scheduleMail({ + envelope, + body, + bodyHTML, + values: template?.values, + }); + }, + { + decorators: [optionalAnyAuth], + } + ); +} + +export const emailFactoryService = new EmailFactoryService(); diff --git a/src/resolve-email-address.ts b/src/services/email-factory/resolve-email-address.ts similarity index 77% rename from src/resolve-email-address.ts rename to src/services/email-factory/resolve-email-address.ts index 7ce78bc..8b2f4d0 100644 --- a/src/resolve-email-address.ts +++ b/src/services/email-factory/resolve-email-address.ts @@ -1,9 +1,13 @@ import { GraphQLError } from "graphql"; import { asEnumKey } from "snek-query"; -import { sq } from "./clients/iam/src/index.js"; -import { Email, LookupTypeInput } from "./clients/iam/src/schema.generated.js"; -import { EmailAddress, EmailAddressType } from "./email-template-factory.js"; +import { sq } from "../../clients/iam/src/index.js"; +import { + Email, + LookupTypeInput, +} from "../../clients/iam/src/schema.generated.js"; +import { EmailAddress } from "./email-template-factory.js"; +import { AuthorizationUser } from "../../repository/.generated.js"; export interface ResolvedFromEmail { firstName: string | null | undefined; @@ -15,7 +19,7 @@ export interface ResolvedFromEmail { export async function resolveFromEmailAddress( email: EmailAddress, authorizationUser: { - id: string; + userId: string; authorization: string; } ): Promise { @@ -24,23 +28,23 @@ export async function resolveFromEmailAddress( const [resolvedEmail, errors] = await sq.query( (q) => { const user = q.user({ - id: authorizationUser.id, + id: authorizationUser.userId, }); let email: Email | undefined; switch (type) { - case EmailAddressType.EMAIL_ADDRESS: + case "EMAIL_ADDRESS": email = user.email({ filter: { emailAddress: value } }); break; - case EmailAddressType.EMAIL_ID: + case "EMAIL_ID": email = user.email({ filter: { emailId: value } }); break; - case EmailAddressType.USER_ID: + case "USER_ID": email = user.email(); break; default: - throw new GraphQLError("Invalid email address type"); + throw new GraphQLError(`Invalid email type: ${type}`); } return { @@ -76,22 +80,22 @@ export async function resolveFromEmailAddress( export async function lookupEmailAddress( email: EmailAddress, authorizationUser: { - id: string; + userId: string; authorization: string; } ) { const { value, type } = email; - if (type === EmailAddressType.EMAIL_ADDRESS) return email.value; + if (type === "EMAIL_ADDRESS") return email.value; const [lookupedEmail, errors] = await sq.query( (q) => { - if (type === EmailAddressType.USER_ID) { + if (type === "USER_ID") { return q.emailLookup({ id: value, type: asEnumKey(LookupTypeInput, "USER_ID"), })?.emailAddress; - } else if (type === EmailAddressType.EMAIL_ID) { + } else if (type === "EMAIL_ID") { return q.emailLookup({ id: value, type: asEnumKey(LookupTypeInput, "EMAIL_ID"), @@ -118,21 +122,21 @@ export async function lookupEmailAddress( export async function verifyReplyToEmailAddress( replyTo: EmailAddress, authorizationUser: { - id: string; + userId: string; authorization: string; } ) { const [_, errors] = await sq.query( (q) => { const user = q.user({ - id: authorizationUser.id, + id: authorizationUser.userId, }); - if (replyTo.type === EmailAddressType.USER_ID) { + if (replyTo.type === "USER_ID") { return user.email().emailAddress; - } else if (replyTo.type === EmailAddressType.EMAIL_ID) { + } else if (replyTo.type === "EMAIL_ID") { return user.email({ filter: { emailId: replyTo.value } }).emailAddress; - } else if (replyTo.type === EmailAddressType.EMAIL_ADDRESS) { + } else if (replyTo.type === "EMAIL_ADDRESS") { return user.email({ filter: { emailAddress: replyTo.value } }) .emailAddress; } diff --git a/src/services/email-factory/transformer-sandbox.ts b/src/services/email-factory/transformer-sandbox.ts new file mode 100644 index 0000000..934d398 --- /dev/null +++ b/src/services/email-factory/transformer-sandbox.ts @@ -0,0 +1,134 @@ +import ivm from "isolated-vm"; +import { EmailTemplate } from "../../repository/.generated"; +import repository from "../../repository"; +import { $Enums } from "@prisma/client"; +import { diff } from "deep-object-diff"; +import { EmailEnvelope } from "./email-template-factory"; + +export interface SandboxTemplate { + id: string; + description: string; + transformer: string | null; + createdAt: Date; + updatedAt: Date; + envelope?: { + subject?: string; + to: Array<{ + value: string; + type: $Enums.EmailAddressType; + }>; + + from?: { + value: string; + type: $Enums.EmailAddressType; + }; + replyTo?: { + value: string; + type: $Enums.EmailAddressType; + }; + } | null; + verifyReplyTo?: boolean; +} + +export const executeInSandbox = async (args: { + input: { + envelope: EmailEnvelope; + values: Record; + body?: string; + bodyHTML?: string; + }; + template: EmailTemplate; + parentTemplate: EmailTemplate | null; +}): Promise<{ + verifyReplyTo: boolean | undefined; + envelope: Partial; +}> => { + const code = args.template.transformer; + + if (!code) { + throw new Error("No transformer code"); + } + + const buildEnvelopeFromTemplate = async ( + template: EmailTemplate + ): Promise> => { + const emailEnvelope = await template.envelope(); + + if (emailEnvelope) { + const to = await emailEnvelope.to(); + const from = await emailEnvelope.from(); + const replyTo = await emailEnvelope.replyTo(); + + return { + subject: emailEnvelope.subject || undefined, + to: to.map((to) => ({ + value: to.value, + type: to.type, + })), + from: from + ? { + value: from?.value || undefined, + type: from?.type || undefined, + } + : (undefined as any), + replyTo: replyTo + ? { + value: replyTo?.value || undefined, + type: replyTo?.type || undefined, + } + : (undefined as any), + }; + } + }; + + const templateEnvelope = await buildEnvelopeFromTemplate(args.template); + const parentTemplateEnvelope = args.parentTemplate + ? await buildEnvelopeFromTemplate(args.parentTemplate) + : undefined; + + // const sandboxTemplate: SandboxTemplate = { + // id: emailTemplate.id, + // description: emailTemplate.description, + // transformer: emailTemplate.transformer, + // createdAt: emailTemplate.createdAt, + // updatedAt: emailTemplate.updatedAt, + // envelope, + // }; + + const isolate = new ivm.Isolate({ memoryLimit: 128 }); + + // Create a new context within this isolate. Each context has its own copy of all the builtin + // Objects. So for instance if one context does Object.prototype.foo = 1 this would not affect any + // other contexts. + const context = isolate.createContextSync(); + + // Get a Reference{} to the global object within the context. + const jail = context.global; + + // This makes the global object available in the context as `global`. We use `derefInto()` here + // because otherwise `global` would actually be a Reference{} object in the new isolate. + jail.setSync("global", jail.derefInto()); + + const hostile = await isolate.compileScript(` + const template = ${JSON.stringify(args.template)}; + const parentTemplate = ${JSON.stringify(args.parentTemplate)}; + + const templateEnvelope = ${JSON.stringify(templateEnvelope)}; + const parentTemplateEnvelope = ${JSON.stringify(parentTemplateEnvelope)}; + const input = ${JSON.stringify(args.input)}; + + let result = { + verifyReplyTo: undefined, + envelope: {}, + }; + ${code} + result; + `); + + // Execute hostile code in the context. + const result = await hostile.run(context, { + copy: true, + }); + + return result; +}; diff --git a/src/services/email-template.ts b/src/services/email-template.ts new file mode 100644 index 0000000..b92797e --- /dev/null +++ b/src/services/email-template.ts @@ -0,0 +1,299 @@ +import { withContext } from "@snek-at/function"; +import { $Enums } from "@prisma/client"; + +import Repository from "../repository"; +import { requireAdminForResource, requireAnyAuth } from "@snek-functions/jwt"; + +export class EmailTemplateService { + all = withContext( + (context) => async () => { + const { resourceId } = context.multiAuth[0]; + + await requireAdminForResource(context, [resourceId]); + + return Repository.EmailTemplate.objects.filter({ + where: { resourceId }, + }); + }, + { + decorators: [requireAnyAuth], + } + ); + + get = withContext( + (context) => async (id: string) => { + const { resourceId } = context.multiAuth[0]; + + await requireAdminForResource(context, [resourceId]); + + return Repository.EmailTemplate.objects.get({ + where: { + id, + resourceId, + }, + }); + }, + { + decorators: [requireAnyAuth], + } + ); + + create = withContext( + (context) => + async (data: { + content: string; + description: string; + authorizationUser?: { + userId: string; + authorization: string; + }; + variables?: { + name: string; + + isRequired?: boolean; + isConstant?: boolean; + description?: string; + defaultValue?: string; + }[]; + + envelope?: { + subject?: string; + to?: Array<{ + value: string; + type: $Enums.EmailAddressType; + }>; + + from?: { + value: string; + type: $Enums.EmailAddressType; + }; + replyTo?: { + value: string; + type: $Enums.EmailAddressType; + }; + }; + }) => { + const { userId, resourceId } = context.multiAuth[0]; + + await requireAdminForResource(context, [resourceId]); + + return Repository.EmailTemplate.objects.create({ + data: { + createdBy: userId, + resourceId, + content: data.content, + description: data.description, + authorizationUser: data.authorizationUser + ? { + create: { + userId: data.authorizationUser.userId, + authorization: data.authorizationUser.authorization, + }, + } + : undefined, + variables: data.variables + ? { + createMany: { + data: data.variables, + }, + } + : undefined, + + envelope: data.envelope + ? { + create: { + subject: data.envelope.subject, + from: data.envelope.from + ? { + create: { + value: data.envelope.from.value, + type: data.envelope.from.type, + }, + } + : undefined, + to: { + create: data.envelope.to?.map((to) => ({ + value: to.value, + type: to.type, + })), + }, + replyTo: data.envelope.replyTo + ? { + create: { + value: data.envelope.replyTo.value, + type: data.envelope.replyTo.type, + }, + } + : undefined, + }, + } + : undefined, + }, + }); + }, + { + decorators: [requireAnyAuth], + } + ); + + update = withContext( + (context) => + async ( + id: string, + data: { + content?: string; + description?: string; + transformer?: string; + authorizationUser?: { + userId: string; + authorization: string; + }; + envelope?: { + subject?: string; + + to?: Array<{ + value: string; + type: $Enums.EmailAddressType; + }>; + + from?: { + value: string; + type: $Enums.EmailAddressType; + }; + replyTo?: { + value: string; + type: $Enums.EmailAddressType; + }; + }; + parentId?: string; + linkedIds?: string[]; + variables?: { + id?: string; + name: string; + isRequired?: boolean; + isConstant?: boolean; + description?: string; + defaultValue?: string; + }[]; + } + ) => { + const { resourceId } = context.multiAuth[0]; + + await requireAdminForResource(context, [resourceId]); + + return Repository.EmailTemplate.objects.update({ + where: { + id, + resourceId, + }, + data: { + content: data.content, + description: data.description, + transformer: data.transformer, + authorizationUser: data.authorizationUser + ? { + upsert: { + create: { + userId: data.authorizationUser.userId, + authorization: data.authorizationUser.authorization, + }, + update: { + userId: data.authorizationUser.userId, + authorization: data.authorizationUser.authorization, + }, + }, + } + : undefined, + envelope: data.envelope + ? { + create: { + subject: data.envelope.subject, + from: data.envelope.from + ? { + create: { + value: data.envelope.from.value, + type: data.envelope.from.type, + }, + } + : undefined, + to: { + create: data.envelope.to?.map((to) => ({ + value: to.value, + type: to.type, + })), + }, + replyTo: data.envelope.replyTo + ? { + create: { + value: data.envelope.replyTo.value, + type: data.envelope.replyTo.type, + }, + } + : undefined, + }, + } + : undefined, + parent: data.parentId + ? { + connect: { + id: data.parentId, + }, + } + : undefined, + linked: { + connect: data.linkedIds?.map((id) => ({ + id, + })), + }, + variables: data.variables + ? { + upsert: data.variables.map((variable) => ({ + where: { + // Ugly hack to make Prisma accept undefined + id: variable.id || "00000000-0000-0000-0000-000000000000", + }, + create: { + name: variable.name, + isRequired: variable.isRequired, + isConstant: variable.isConstant, + description: variable.description, + defaultValue: variable.defaultValue, + }, + update: { + name: variable.name, + isRequired: variable.isRequired, + isConstant: variable.isConstant, + description: variable.description, + defaultValue: variable.defaultValue, + }, + })), + } + : undefined, + }, + }); + }, + { + decorators: [requireAnyAuth], + } + ); + + delete = withContext( + (context) => async (id: string) => { + const { resourceId } = context.multiAuth[0]; + + await requireAdminForResource(context, [resourceId]); + + return Repository.EmailTemplate.objects.delete({ + where: { + id, + resourceId, + }, + }); + }, + { + decorators: [requireAnyAuth], + } + ); +} + +export const emailTemplateService = new EmailTemplateService(); diff --git a/src/sfi.ts b/src/sfi.ts index 92ea73b..059608a 100644 --- a/src/sfi.ts +++ b/src/sfi.ts @@ -1,97 +1,27 @@ -import { - Context, - decorator, - defineService, - withContext, -} from "@snek-at/function"; -import { - AuthenticationContext, - AuthenticationRequiredError, - requireAnyAuth, -} from "@snek-functions/jwt"; +import { defineService } from "@snek-at/function"; import * as dotenv from "dotenv"; // see https://github.com/motdotla/dotenv#how-do-i-use-dotenv-with-import -import { EmailEngine } from "./email-engine"; -import { - EmailEnvelope, - EmailTemplateFactory, - TemplateVariableValues, -} from "./email-template-factory"; -import { requireAdminOnMailpress } from "./decorators"; -dotenv.config(); - -const optionalAnyAuth = decorator(async (context, args) => { - let ctx: Context<{ - auth?: AuthenticationContext["auth"]; - multiAuth?: AuthenticationContext["multiAuth"]; - }> = context; +import { emailFactoryService } from "./services/email-factory"; +import { emailTemplateService } from "./services/email-template"; - try { - ctx = await requireAnyAuth(context, args); - } catch (e) { - if (!(e instanceof AuthenticationRequiredError)) { - throw e; - } - } - - return ctx; -}); +dotenv.config(); export default defineService( { Query: { - template: withContext(() => EmailTemplateFactory.getTemplate, { - decorators: [requireAdminOnMailpress], - }), - allTemplate: withContext(() => EmailTemplateFactory.getTemplates, { - decorators: [requireAdminOnMailpress], - }), + template: emailTemplateService.get, + allTemplate: emailTemplateService.all, }, Mutation: { - mailSchedule: withContext( - (context) => - async ( - envelope: EmailEnvelope, - body?: string, - bodyHTML?: string, - template?: { - id: string; - values?: TemplateVariableValues; - } - ) => { - const originUserId = context.multiAuth?.[0].userId; - - const emailTemplate = template?.id - ? EmailTemplateFactory.getTemplate(template?.id) - : undefined; - - const engine = new EmailEngine({ - template: emailTemplate, - authorizationUser: originUserId - ? { - id: originUserId, - authorization: context.req.headers.authorization!, - } - : undefined, - }); - - return await engine.scheduleMail({ - envelope, - body, - bodyHTML, - values: template?.values, - }); - }, - { - decorators: [optionalAnyAuth], - } - ), - createTemplate: EmailTemplateFactory.createTemplate, - }, - }, - { - configureApp: () => { - import("./init-templates"); + templateCreate: emailTemplateService.create, + templateUpdate: emailTemplateService.update, + templateDelete: emailTemplateService.delete, + mailSchedule: emailFactoryService.mailSchedule, }, } + // { + // configureApp: () => { + // import("./init-templates"); + // }, + // } ); diff --git a/templates/agt-contact-confirmation-email.html b/templates/agt-contact-confirmation-email.html deleted file mode 100644 index 14bedd5..0000000 --- a/templates/agt-contact-confirmation-email.html +++ /dev/null @@ -1,1307 +0,0 @@ - - - - - - - - - Neue Bestellung - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Kontaktanfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date("j. F Y \u\m G:i", "de") }} -

-
-

- Vielen Dank für Ihre Kontaktanfrage. Wir - werden uns so schnell wie möglich bei - Ihnen melden. -

-
-
-
- - - - -
- - - - -
-
    -
  • Name: {{name}}
  • -
  • Email: {{email}}
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - info@agt-guntrade.at oder rufen Sie uns unter +43 (0) - 676 6510977 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/agt-contact-email.html b/templates/agt-contact-email.html deleted file mode 100644 index 95f7bb1..0000000 --- a/templates/agt-contact-email.html +++ /dev/null @@ -1,1317 +0,0 @@ - - - - - - - - - Kontaktanfrage - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Kontaktanfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date("j. F Y \u\m G:i", "de") }} -

-
-

- Eine neue Kontaktanfrage wurde über das - Kontaktformular auf der Website - AGT Gun Trade - erstellt. -

-
-
-
- - - - -
- - - - -
-
    -
  • - URL: {{invokedOnUrl}} -
  • -
  • Name: {{name}}
  • -
  • Email: {{email}}
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - info@agt-guntrade.at oder rufen Sie uns unter +43 (0) - 676 6510977 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/agt-order-confirmation-email.html b/templates/agt-order-confirmation-email.html deleted file mode 100644 index e5275cb..0000000 --- a/templates/agt-order-confirmation-email.html +++ /dev/null @@ -1,1307 +0,0 @@ - - - - - - - - - Neue Anfrage - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Neue Anfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date("j. F Y \u\m G:i", "de") }} -

-
-

- Vielen Dank für Ihre Anfrage. Wir werden - uns so schnell wie möglich bei Ihnen - melden. -

-
-
-
- - - - -
- - - - -
-
    -
  • Name: {{name}}
  • -
  • Email: {{email}}
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - info@agt-guntrade.at oder rufen Sie uns unter +43 (0) - 676 6510977 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/agt-order-email.html b/templates/agt-order-email.html deleted file mode 100644 index ec51af1..0000000 --- a/templates/agt-order-email.html +++ /dev/null @@ -1,1317 +0,0 @@ - - - - - - - - - Neue Anfrage - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Neue Anfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date("j. F Y \u\m G:i", "de") }} -

-
-

- Eine neue Produktanfrage wurde auf der - Website - AGT Gun Trade - erstellt. -

-
-
-
- - - - -
- - - - -
-
    -
  • - URL: {{invokedOnUrl}} -
  • -
  • Name: {{name}}
  • -
  • Email: {{email}}
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - info@agt-guntrade.at oder rufen Sie uns unter +43 (0) - 676 6510977 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/ballons-ballons-contact-confirmation-email.html b/templates/ballons-ballons-contact-confirmation-email.html deleted file mode 100644 index 510f9f1..0000000 --- a/templates/ballons-ballons-contact-confirmation-email.html +++ /dev/null @@ -1,1401 +0,0 @@ - - - - - - - - - Neue Anfrage - - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Kontaktanfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date('d/m/Y') }} -

-
-

- Vielen Dank für Ihre Kontaktanfrage. Wir - werden uns so schnell wie möglich bei - Ihnen melden. -

-
-
-
- - - - -
- - - - -
-
    -
  • - Name: {{firstName}} - {{lastName}} -
  • -
  • Email: {{email}}
  • -
  • - Telefonnummer: {{phone}} -
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - office@ballons-ballons.at oder rufen Sie uns unter +43 1 216 - 34 25 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/ballons-ballons-contact-email.html b/templates/ballons-ballons-contact-email.html deleted file mode 100644 index d8c011f..0000000 --- a/templates/ballons-ballons-contact-email.html +++ /dev/null @@ -1,1411 +0,0 @@ - - - - - - - - - Neue Anfrage - - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Kontaktanfrage -

-
-
-
-
- - - - -
- - - - -
- - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date('d/m/Y') }} -

-
-

- Eine neue Kontaktanfrage wurde über das - Kontaktformular auf der Website - Ballons & Ballons - erstellt. -

-
-
-
- - - - -
- - - - -
-
    -
  • - URL: {{invokedOnUrl}} -
  • -
  • - Name: {{firstName}} - {{lastName}} -
  • - , -
  • Email: {{email}}
  • -
  • - Telefonnummer: {{phone}} -
  • -
  • - Message: {{message}} -
  • -
-
-
-
- - - - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - office@ballons-ballons.at oder rufen Sie uns unter +43 1 216 - 34 25 an. -

-
-
-
-
- - - - - -
-
- - diff --git a/templates/ballons-ballons-order-confirmation-email.html b/templates/ballons-ballons-order-confirmation-email.html deleted file mode 100644 index de80068..0000000 --- a/templates/ballons-ballons-order-confirmation-email.html +++ /dev/null @@ -1,1473 +0,0 @@ - - - - - - - - - - Ihre Anfrage - - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - - - - - - - -
-

- Ihre Anfrage -

-
-
- Diese E-Mail dient nicht als Kaufbestätigung, - sondern lediglich zur Bestätigung, dass Ihre Anfrage erfolgreich in unserem - System erfasst wurde. -
-
-
- - Aufgrund eines sehr hohen Aufkommens an Aufträgen kann es aktuell zu einer - Wartezeit von
bis zu 2 Werktagen bei der Bearbeitung Ihrer Anfrage - kommen!
-
-
-
-
-
- - - - -
- - - - -
- {% for product in cart %} -
- - - {% endfor %} - - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date('d/m/Y') }} -

-
-

- Sehr geehrte/r {{customer.firstName}} {{customer.lastName}}, - herzlichen Dank für Ihre Anfrage in - unserem Online-Shop. Wir werden uns so - schnell wie möglich mit genaueren Details - bei Ihnen melden. -

-
-
-
- - - - - - - - - - -
- - - - -
- - - - -
- -
-
-
- - - - -
- - - - - - - -
-

- {{product.name}} -

-
-

- ArtikelNr. {{product.sku}} -

-
-
-
- - - - -
- - - - -
-

- {{product.quantity}} -

-
-
-
- - - - -
- - - - - - - -
-

- {{(product.price * product.quantity)|format_currency('EUR')}}
-

-
- - {% if not wholesale %}inkl.{% else %}exkl.{% endif %} USt. - -
-
-
-
- - {% if order.note %} - - - - {% endif %} - - - -
-
- Zusätzliche - Informationen zur Anfrage:
{{ - order.note }}
-
-
- - -
-
-
- - - - - -
- - - - - - - - - - - - - -
-

- Kunde: -

-
-

- {{customer.firstName}} {{customer.lastName}} -

-
-

- {{customer.emailAddress}} -

-
-

- {{customer.phone}} -

-
-
- - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - office@ballons-ballons.at oder rufen Sie uns unter +43 1 216 34 25 an. -

-
-
-
-
- - - - - -
-
- - - diff --git a/templates/ballons-ballons-order-email.html b/templates/ballons-ballons-order-email.html deleted file mode 100644 index 47d7aee..0000000 --- a/templates/ballons-ballons-order-email.html +++ /dev/null @@ -1,1417 +0,0 @@ - - - - - - - - - - Neue Anfrage{% if wholesale %} im Großhandel{% endif %} - - - - - - - -
- - - - - -
- - - - -
- - - - -
- - - - -
- - - - -
- Logo -
-
-
-
- - - - -
- - - - -
- - - - -
- - - - -
-

- Neue Anfrage{% if wholesale %} im Großhandel{% endif %} -

-
-
-
-
- - - - -
- - - - -
- {% for product in cart %} -
- - - {% endfor %} - - - - - - - - - - - -
- - - - -
- - - - - - - -
-

- {{ "now"|date('d/m/Y') }} -

-
-

- Eine neue Anfrage wurde auf deinem - Online-Shop aufgegeben. -

-
-
-
- - - - - - - - - -
- - - - -
- - - - -
-

- {{product.name}} - ({{product.sku}}) -

-
-
-
- - - - -
- - - - -
-

- {{product.quantity}} -

-
-
-
- - - - -
- - - - -
-

- {{(product.price * - product.quantity)|format_currency('EUR')}} -

- - - {% if not wholesale %}inkl.{% else %}exkl.{% endif %} USt. - -
-
-
-
- - - - -
- - {% if order.note %} - - - - {% endif %} - - - -
-

- Zusätzliche - Informationen zur Anfrage:
{{ - order.note }}
-

-
-

- Zwischensumme:  - {{order.totalPrice|format_currency('EUR')}} -

- -

- Achtung: Überprüfen Sie die angegebenen - Preise der Artikel. Diese können von den - tatsächlichen Preisen abweichen. -

-
-
-
- - - - - -
- - - - - - - - - - - - - - - - - -
-

- Kunde: -

-
-

- {{customer.firstName}}{{customer.lastName}} -

-
-

- {{customer.emailAddress}} -

-
-

- {{customer.phone}} -

-
-
- - - - - -
- - - - -
-

-
-
- -
- - - - -
- - - - -
-

- Haben Sie eine Frage? Schreiben Sie uns - eine E-Mail an - office@ballons-ballons.at oder rufen Sie uns unter +43 1 216 34 25 - an. -

-
-
-
-
- - - - - -
-
- - -