@jarwizz/create-jarshop 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/dist/generator.js +6 -3
- package/dist/generator.js.map +1 -1
- package/dist/template/CONTEXT.md +10 -0
- package/dist/template/README.md +20 -3
- package/dist/template/docker-compose.yml +5 -0
- package/dist/template/docs/smtp-smoke.md +37 -0
- package/dist/template/env.example.template +18 -0
- package/dist/template/gitignore.template +2 -0
- package/dist/template/package.json +13 -5
- package/dist/template/pnpm-lock.yaml +38 -7
- package/dist/template/src/dev-storefront.ts +58 -0
- package/dist/template/src/dev.ts +67 -4
- package/dist/template/src/email-smoke.ts +27 -0
- package/dist/template/src/environment.d.ts +6 -0
- package/dist/template/src/migrate.ts +16 -0
- package/dist/template/src/migrations/1786024108434-jarshop-checkout-attempt.ts +28 -0
- package/dist/template/src/migrations/1786024108435-jarshop-legal-and-confirmation.ts +60 -0
- package/dist/template/src/migrations/1786024108436-jarshop-order-email-outbox.ts +37 -0
- package/dist/template/src/migrations/1786030000000-jarshop-order-email-delivery-lease.ts +27 -0
- package/dist/template/src/plugins/jarshop-checkout/checkout-attempt.entity.ts +42 -0
- package/dist/template/src/plugins/jarshop-checkout/confirmation-token.entity.ts +31 -0
- package/dist/template/src/plugins/jarshop-checkout/confirmation.ts +26 -0
- package/dist/template/src/plugins/jarshop-checkout/dashboard/index.tsx +107 -0
- package/dist/template/src/plugins/jarshop-checkout/dashboard/order-email-delivery-status.ts +43 -0
- package/dist/template/src/plugins/jarshop-checkout/email-smoke-core.ts +56 -0
- package/dist/template/src/plugins/jarshop-checkout/index.ts +47 -0
- package/dist/template/src/plugins/jarshop-checkout/jarshop-checkout.plugin.ts +813 -0
- package/dist/template/src/plugins/jarshop-checkout/legal.entity.ts +39 -0
- package/dist/template/src/plugins/jarshop-checkout/order-consent.entity.ts +32 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-core.ts +337 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery-status.ts +39 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-delivery.entity.ts +66 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-outbox.ts +108 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-transport.ts +35 -0
- package/dist/template/src/plugins/jarshop-checkout/order-email-worker.ts +281 -0
- package/dist/template/src/plugins/jarshop-checkout/shipping.test.ts +41 -0
- package/dist/template/src/plugins/jarshop-checkout/shipping.ts +162 -0
- package/dist/template/src/seed.ts +149 -0
- package/dist/template/src/vendure-config.ts +68 -5
- package/dist/template/static/email/templates/email-verification/body.hbs +4 -4
- package/dist/template/storefront/app/[locale]/[slug]/add-to-cart-form.tsx +44 -0
- package/dist/template/storefront/app/[locale]/[slug]/page.tsx +35 -26
- package/dist/template/storefront/app/[locale]/account/account-mutation-form.tsx +44 -0
- package/dist/template/storefront/app/[locale]/account/change-password-form.tsx +65 -0
- package/dist/template/storefront/app/[locale]/account/login/login-form.tsx +60 -0
- package/dist/template/storefront/app/[locale]/account/login/page.tsx +23 -0
- package/dist/template/storefront/app/[locale]/account/page.tsx +215 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/callback/route.ts +33 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/complete/page.tsx +27 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/page.tsx +37 -0
- package/dist/template/storefront/app/[locale]/account/password-reset/password-reset-forms.tsx +99 -0
- package/dist/template/storefront/app/[locale]/account/register/page.tsx +23 -0
- package/dist/template/storefront/app/[locale]/account/register/register-form.tsx +67 -0
- package/dist/template/storefront/app/[locale]/account/verify/complete/route.ts +33 -0
- package/dist/template/storefront/app/[locale]/account/verify/page.tsx +41 -0
- package/dist/template/storefront/app/[locale]/account/verify/refresh-form.tsx +35 -0
- package/dist/template/storefront/app/[locale]/cart/page.tsx +78 -0
- package/dist/template/storefront/app/[locale]/checkout/checkout-form.tsx +112 -0
- package/dist/template/storefront/app/[locale]/checkout/page.tsx +44 -0
- package/dist/template/storefront/app/[locale]/checkout/success/page.tsx +23 -0
- package/dist/template/storefront/app/[locale]/layout.tsx +48 -0
- package/dist/template/storefront/app/[locale]/page.tsx +10 -11
- package/dist/template/storefront/app/globals.css +372 -4
- package/dist/template/storefront/app/layout.tsx +13 -0
- package/dist/template/storefront/eslint.config.mjs +1 -1
- package/dist/template/storefront/gitignore.template +1 -0
- package/dist/template/storefront/lib/account-security.test.ts +74 -0
- package/dist/template/storefront/lib/account-security.ts +81 -0
- package/dist/template/storefront/lib/account.ts +366 -0
- package/dist/template/storefront/lib/cart-contract.ts +52 -0
- package/dist/template/storefront/lib/cart.test.ts +30 -0
- package/dist/template/storefront/lib/cart.ts +130 -0
- package/dist/template/storefront/lib/checkout-contract.test.ts +20 -0
- package/dist/template/storefront/lib/checkout-contract.ts +16 -0
- package/dist/template/storefront/lib/checkout.ts +143 -0
- package/dist/template/storefront/lib/commerce-session.test.ts +77 -0
- package/dist/template/storefront/lib/commerce-session.ts +85 -0
- package/dist/template/storefront/lib/commerce.ts +122 -1
- package/dist/template/storefront/lib/confirmation-cookie.ts +9 -0
- package/dist/template/storefront/lib/confirmation.ts +47 -0
- package/dist/template/storefront/lib/form-values.test.ts +14 -0
- package/dist/template/storefront/lib/form-values.ts +7 -0
- package/dist/template/storefront/lib/i18n.test.ts +50 -0
- package/dist/template/storefront/lib/i18n.ts +44 -0
- package/dist/template/storefront/lib/password-reset-token.test.ts +31 -0
- package/dist/template/storefront/lib/password-reset-token.ts +17 -0
- package/dist/template/storefront/lib/valkey-rate-limit.ts +72 -0
- package/dist/template/storefront/messages/en.json +89 -0
- package/dist/template/storefront/messages/sk.json +89 -0
- package/dist/template/storefront/next.config.ts +3 -1
- package/dist/template/storefront/package.json +4 -1
- package/dist/template/storefront/pnpm-lock.yaml +756 -2
- package/dist/template/tsconfig.json +2 -1
- package/dist/template/turbo.json +1 -0
- package/dist/template/vite.config.mts +4 -1
- package/dist/template-manifest.json +1 -1
- package/dist/template.js +3 -0
- package/dist/template.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Column,
|
|
3
|
+
CreateDateColumn,
|
|
4
|
+
Entity,
|
|
5
|
+
PrimaryGeneratedColumn,
|
|
6
|
+
Unique,
|
|
7
|
+
UpdateDateColumn,
|
|
8
|
+
} from "typeorm";
|
|
9
|
+
|
|
10
|
+
@Entity({ name: "jarshop_legal_configuration" })
|
|
11
|
+
@Unique("UQ_jarshop_legal_channel_language", ["channelId", "languageCode"])
|
|
12
|
+
export class JarShopLegalConfiguration {
|
|
13
|
+
@PrimaryGeneratedColumn()
|
|
14
|
+
id!: number;
|
|
15
|
+
|
|
16
|
+
@Column({ type: "int" })
|
|
17
|
+
channelId!: number;
|
|
18
|
+
|
|
19
|
+
@Column({ type: "varchar", length: 10 })
|
|
20
|
+
languageCode!: string;
|
|
21
|
+
|
|
22
|
+
@Column({ type: "varchar", length: 64 })
|
|
23
|
+
termsVersion!: string;
|
|
24
|
+
|
|
25
|
+
@Column({ type: "varchar", length: 2048 })
|
|
26
|
+
termsUrl!: string;
|
|
27
|
+
|
|
28
|
+
@Column({ type: "varchar", length: 64 })
|
|
29
|
+
privacyVersion!: string;
|
|
30
|
+
|
|
31
|
+
@Column({ type: "varchar", length: 2048 })
|
|
32
|
+
privacyUrl!: string;
|
|
33
|
+
|
|
34
|
+
@CreateDateColumn()
|
|
35
|
+
createdAt!: Date;
|
|
36
|
+
|
|
37
|
+
@UpdateDateColumn()
|
|
38
|
+
updatedAt!: Date;
|
|
39
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Column,
|
|
3
|
+
CreateDateColumn,
|
|
4
|
+
Entity,
|
|
5
|
+
PrimaryGeneratedColumn,
|
|
6
|
+
Unique,
|
|
7
|
+
} from "typeorm";
|
|
8
|
+
|
|
9
|
+
@Entity({ name: "jarshop_order_consent" })
|
|
10
|
+
export class JarShopOrderConsent {
|
|
11
|
+
@PrimaryGeneratedColumn()
|
|
12
|
+
id!: number;
|
|
13
|
+
|
|
14
|
+
@Unique("UQ_jarshop_order_consent_order", ["orderId"])
|
|
15
|
+
@Column({ type: "int" })
|
|
16
|
+
orderId!: number;
|
|
17
|
+
|
|
18
|
+
@Column({ type: "varchar", length: 64 })
|
|
19
|
+
termsVersion!: string;
|
|
20
|
+
|
|
21
|
+
@Column({ type: "varchar", length: 2048 })
|
|
22
|
+
termsUrl!: string;
|
|
23
|
+
|
|
24
|
+
@Column({ type: "varchar", length: 2048 })
|
|
25
|
+
privacyUrl!: string;
|
|
26
|
+
|
|
27
|
+
@Column({ type: "timestamptz" })
|
|
28
|
+
acceptedAt!: Date;
|
|
29
|
+
|
|
30
|
+
@CreateDateColumn()
|
|
31
|
+
createdAt!: Date;
|
|
32
|
+
}
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const fileTransport = "file";
|
|
5
|
+
const defaultStorefrontUrl = "http://localhost:3001";
|
|
6
|
+
const defaultOutputPath = "./static/email/jarshop-outbox";
|
|
7
|
+
|
|
8
|
+
export type OrderEmailLanguageCode = "sk" | "en";
|
|
9
|
+
|
|
10
|
+
export type OrderConfirmationEmail = {
|
|
11
|
+
recipient: string;
|
|
12
|
+
subject: string;
|
|
13
|
+
text: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OrderEmailTransport = {
|
|
17
|
+
readonly kind: "file" | "smtp";
|
|
18
|
+
deliver(input: {
|
|
19
|
+
deliveryId: number;
|
|
20
|
+
email: OrderConfirmationEmail;
|
|
21
|
+
}): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export type SmtpOrderEmailTransportConfiguration = {
|
|
25
|
+
kind: "smtp";
|
|
26
|
+
host: string;
|
|
27
|
+
port: number;
|
|
28
|
+
secure: boolean;
|
|
29
|
+
from: string;
|
|
30
|
+
username: string;
|
|
31
|
+
password: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type OrderEmailTransportConfiguration =
|
|
35
|
+
| {
|
|
36
|
+
kind: "file";
|
|
37
|
+
outputPath?: string;
|
|
38
|
+
}
|
|
39
|
+
| SmtpOrderEmailTransportConfiguration;
|
|
40
|
+
|
|
41
|
+
type Environment = Readonly<Record<string, string | undefined>>;
|
|
42
|
+
|
|
43
|
+
export type OrderConfirmationEmailInput = {
|
|
44
|
+
recipient: string;
|
|
45
|
+
languageCode: string;
|
|
46
|
+
orderCode: string;
|
|
47
|
+
currencyCode: string;
|
|
48
|
+
totalWithTax: number;
|
|
49
|
+
shippingWithTax: number;
|
|
50
|
+
paymentMethod: string;
|
|
51
|
+
shippingMethod: string;
|
|
52
|
+
confirmationUrl: string;
|
|
53
|
+
lines: ReadonlyArray<{
|
|
54
|
+
name: string;
|
|
55
|
+
quantity: number;
|
|
56
|
+
totalWithTax: number;
|
|
57
|
+
}>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export const orderEmailDeliveryPolicy = {
|
|
61
|
+
maximumAttempts: 5,
|
|
62
|
+
leaseDurationMs: 2 * 60 * 1_000,
|
|
63
|
+
retryDelaysMs: [60_000, 5 * 60_000, 30 * 60_000, 2 * 60 * 60_000],
|
|
64
|
+
} as const;
|
|
65
|
+
|
|
66
|
+
export function scheduleOrderEmailRetry(input: {
|
|
67
|
+
attemptCount: number;
|
|
68
|
+
now: Date;
|
|
69
|
+
}): { status: "pending"; nextAttemptAt: Date } | { status: "failed" } {
|
|
70
|
+
if (!Number.isInteger(input.attemptCount) || input.attemptCount < 1) {
|
|
71
|
+
throw new Error("order email attempt count must be a positive integer");
|
|
72
|
+
}
|
|
73
|
+
if (input.attemptCount >= orderEmailDeliveryPolicy.maximumAttempts) {
|
|
74
|
+
return { status: "failed" };
|
|
75
|
+
}
|
|
76
|
+
const delay = orderEmailDeliveryPolicy.retryDelaysMs[input.attemptCount - 1];
|
|
77
|
+
if (delay === undefined) {
|
|
78
|
+
throw new Error("order email retry delay is not configured");
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
status: "pending",
|
|
82
|
+
nextAttemptAt: new Date(input.now.getTime() + delay),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function registerUniqueScheduledTask<
|
|
87
|
+
Config extends { schedulerOptions: { tasks: Task[] } },
|
|
88
|
+
Task extends { id: string },
|
|
89
|
+
>(config: Config, task: Task): Config {
|
|
90
|
+
if (
|
|
91
|
+
!config.schedulerOptions.tasks.some((candidate) => candidate.id === task.id)
|
|
92
|
+
) {
|
|
93
|
+
config.schedulerOptions.tasks.push(task);
|
|
94
|
+
}
|
|
95
|
+
return config;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function assertOrderEmailDeliveryConfiguration(): void {
|
|
99
|
+
readOrderEmailTransportConfiguration(process.env);
|
|
100
|
+
const storefrontUrl =
|
|
101
|
+
process.env.JARSHOP_STOREFRONT_URL ?? defaultStorefrontUrl;
|
|
102
|
+
const parsed = new URL(storefrontUrl);
|
|
103
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
104
|
+
throw new Error("JARSHOP_STOREFRONT_URL must use HTTP or HTTPS");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function readOrderEmailTransportConfiguration(
|
|
109
|
+
environment: Environment,
|
|
110
|
+
): OrderEmailTransportConfiguration {
|
|
111
|
+
const appEnvironment = environment.APP_ENV;
|
|
112
|
+
const transport =
|
|
113
|
+
environment.JARSHOP_EMAIL_TRANSPORT ??
|
|
114
|
+
(appEnvironment === "dev" || appEnvironment === "test"
|
|
115
|
+
? fileTransport
|
|
116
|
+
: undefined);
|
|
117
|
+
|
|
118
|
+
if (transport === undefined) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
"JARSHOP_EMAIL_TRANSPORT must be explicitly set outside APP_ENV=dev or APP_ENV=test",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (transport === fileTransport) {
|
|
124
|
+
if (appEnvironment !== "dev" && appEnvironment !== "test") {
|
|
125
|
+
throw new Error(
|
|
126
|
+
"file email transport is allowed only for APP_ENV=dev or APP_ENV=test",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
kind: fileTransport,
|
|
131
|
+
...(environment.JARSHOP_EMAIL_FILE_TRANSPORT_PATH === undefined
|
|
132
|
+
? {}
|
|
133
|
+
: { outputPath: environment.JARSHOP_EMAIL_FILE_TRANSPORT_PATH }),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
if (transport !== "smtp") {
|
|
137
|
+
throw new Error("JARSHOP_EMAIL_TRANSPORT must be file or smtp");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
kind: "smtp",
|
|
142
|
+
host: requireEnvironmentValue(environment, "JARSHOP_EMAIL_SMTP_HOST"),
|
|
143
|
+
port: parseSmtpPort(
|
|
144
|
+
requireEnvironmentValue(environment, "JARSHOP_EMAIL_SMTP_PORT"),
|
|
145
|
+
),
|
|
146
|
+
secure: parseSmtpSecure(
|
|
147
|
+
requireEnvironmentValue(environment, "JARSHOP_EMAIL_SMTP_SECURE"),
|
|
148
|
+
),
|
|
149
|
+
from: requireEnvironmentValue(environment, "JARSHOP_EMAIL_SMTP_FROM"),
|
|
150
|
+
username: requireEnvironmentValue(
|
|
151
|
+
environment,
|
|
152
|
+
"JARSHOP_EMAIL_SMTP_USERNAME",
|
|
153
|
+
),
|
|
154
|
+
password: requireEnvironmentValue(
|
|
155
|
+
environment,
|
|
156
|
+
"JARSHOP_EMAIL_SMTP_PASSWORD",
|
|
157
|
+
),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function createFileOrderEmailTransport(
|
|
162
|
+
input: {
|
|
163
|
+
outputPath?: string;
|
|
164
|
+
} = {},
|
|
165
|
+
): OrderEmailTransport {
|
|
166
|
+
return {
|
|
167
|
+
kind: "file",
|
|
168
|
+
deliver: async ({ deliveryId, email }) => {
|
|
169
|
+
await writeSanitizedFileTransport({
|
|
170
|
+
deliveryId,
|
|
171
|
+
email,
|
|
172
|
+
outputPath: input.outputPath,
|
|
173
|
+
});
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function createSmtpOrderEmailTransport(
|
|
179
|
+
configuration: SmtpOrderEmailTransportConfiguration,
|
|
180
|
+
sendMail: (message: {
|
|
181
|
+
from: string;
|
|
182
|
+
to: string;
|
|
183
|
+
subject: string;
|
|
184
|
+
text: string;
|
|
185
|
+
}) => Promise<unknown>,
|
|
186
|
+
): OrderEmailTransport {
|
|
187
|
+
return {
|
|
188
|
+
kind: "smtp",
|
|
189
|
+
deliver: async ({ email }) => {
|
|
190
|
+
await sendMail({
|
|
191
|
+
from: configuration.from,
|
|
192
|
+
to: email.recipient,
|
|
193
|
+
subject: email.subject,
|
|
194
|
+
text: email.text,
|
|
195
|
+
});
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function renderOrderConfirmationEmail(
|
|
201
|
+
input: OrderConfirmationEmailInput,
|
|
202
|
+
): OrderConfirmationEmail {
|
|
203
|
+
const languageCode = toOrderEmailLanguageCode(input.languageCode);
|
|
204
|
+
const copy = emailCopy[languageCode];
|
|
205
|
+
const formatMoney = new Intl.NumberFormat(
|
|
206
|
+
languageCode === "sk" ? "sk-SK" : "en-GB",
|
|
207
|
+
{ style: "currency", currency: input.currencyCode },
|
|
208
|
+
);
|
|
209
|
+
const money = (minorUnits: number) => formatMoney.format(minorUnits / 100);
|
|
210
|
+
const lines = input.lines.map(
|
|
211
|
+
(line) => `- ${line.quantity}× ${line.name}: ${money(line.totalWithTax)}`,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
recipient: input.recipient,
|
|
216
|
+
subject: `${copy.subject} #${input.orderCode}`,
|
|
217
|
+
text: [
|
|
218
|
+
`${copy.greeting} #${input.orderCode}.`,
|
|
219
|
+
"",
|
|
220
|
+
copy.items,
|
|
221
|
+
...lines,
|
|
222
|
+
"",
|
|
223
|
+
`${copy.shipping}: ${input.shippingMethod || copy.notAvailable} (${money(input.shippingWithTax)})`,
|
|
224
|
+
`${copy.payment}: ${input.paymentMethod || copy.notAvailable}`,
|
|
225
|
+
`${copy.total}: ${money(input.totalWithTax)}`,
|
|
226
|
+
"",
|
|
227
|
+
`${copy.confirmation}: ${input.confirmationUrl}`,
|
|
228
|
+
].join("\n"),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function writeSanitizedFileTransport(input: {
|
|
233
|
+
deliveryId: number;
|
|
234
|
+
email: OrderConfirmationEmail;
|
|
235
|
+
outputPath?: string;
|
|
236
|
+
}): Promise<string> {
|
|
237
|
+
if (!Number.isSafeInteger(input.deliveryId) || input.deliveryId <= 0) {
|
|
238
|
+
throw new Error("order email delivery id must be a positive integer");
|
|
239
|
+
}
|
|
240
|
+
const outputPath = path.resolve(
|
|
241
|
+
input.outputPath ??
|
|
242
|
+
process.env.JARSHOP_EMAIL_FILE_TRANSPORT_PATH ??
|
|
243
|
+
defaultOutputPath,
|
|
244
|
+
);
|
|
245
|
+
await mkdir(outputPath, { recursive: true });
|
|
246
|
+
const filePath = path.join(
|
|
247
|
+
outputPath,
|
|
248
|
+
`${input.deliveryId}.order-confirmation.txt`,
|
|
249
|
+
);
|
|
250
|
+
const temporaryPath = `${filePath}.tmp`;
|
|
251
|
+
await writeFile(
|
|
252
|
+
temporaryPath,
|
|
253
|
+
sanitizeFileTransportEmail(input.email),
|
|
254
|
+
"utf8",
|
|
255
|
+
);
|
|
256
|
+
await rename(temporaryPath, filePath);
|
|
257
|
+
return filePath;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function sanitizeFileTransportEmail(
|
|
261
|
+
email: OrderConfirmationEmail,
|
|
262
|
+
): string {
|
|
263
|
+
return [
|
|
264
|
+
"To: [redacted]",
|
|
265
|
+
`Subject: ${email.subject}`,
|
|
266
|
+
"",
|
|
267
|
+
email.text.replace(/([?&]token=)[^\s]+/gu, "$1[redacted]"),
|
|
268
|
+
].join("\n");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export function createOrderConfirmationUrl(
|
|
272
|
+
languageCode: OrderEmailLanguageCode,
|
|
273
|
+
token: string,
|
|
274
|
+
): string {
|
|
275
|
+
const storefrontUrl =
|
|
276
|
+
process.env.JARSHOP_STOREFRONT_URL ?? defaultStorefrontUrl;
|
|
277
|
+
return new URL(
|
|
278
|
+
`/${languageCode}/checkout/success?token=${encodeURIComponent(token)}`,
|
|
279
|
+
storefrontUrl,
|
|
280
|
+
).toString();
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function toOrderEmailLanguageCode(
|
|
284
|
+
languageCode: string,
|
|
285
|
+
): OrderEmailLanguageCode {
|
|
286
|
+
return languageCode === "sk" ? "sk" : "en";
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function requireEnvironmentValue(
|
|
290
|
+
environment: Environment,
|
|
291
|
+
name: string,
|
|
292
|
+
): string {
|
|
293
|
+
const value = environment[name]?.trim();
|
|
294
|
+
if (value === undefined || value.length === 0) {
|
|
295
|
+
throw new Error(`${name} must be set when JARSHOP_EMAIL_TRANSPORT=smtp`);
|
|
296
|
+
}
|
|
297
|
+
return value;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function parseSmtpPort(value: string): number {
|
|
301
|
+
const port = Number(value);
|
|
302
|
+
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
"JARSHOP_EMAIL_SMTP_PORT must be an integer from 1 to 65535",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return port;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseSmtpSecure(value: string): boolean {
|
|
311
|
+
if (value === "true") return true;
|
|
312
|
+
if (value === "false") return false;
|
|
313
|
+
throw new Error("JARSHOP_EMAIL_SMTP_SECURE must be true or false");
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const emailCopy = {
|
|
317
|
+
sk: {
|
|
318
|
+
subject: "Potvrdenie objednávky",
|
|
319
|
+
greeting: "Ďakujeme za objednávku",
|
|
320
|
+
items: "Položky objednávky:",
|
|
321
|
+
shipping: "Doprava",
|
|
322
|
+
payment: "Platba",
|
|
323
|
+
total: "Celkom s DPH",
|
|
324
|
+
confirmation: "Potvrdenie objednávky",
|
|
325
|
+
notAvailable: "neuvedené",
|
|
326
|
+
},
|
|
327
|
+
en: {
|
|
328
|
+
subject: "Order confirmation",
|
|
329
|
+
greeting: "Thank you for your order",
|
|
330
|
+
items: "Order items:",
|
|
331
|
+
shipping: "Shipping",
|
|
332
|
+
payment: "Payment",
|
|
333
|
+
total: "Total including tax",
|
|
334
|
+
confirmation: "Order confirmation",
|
|
335
|
+
notAvailable: "not available",
|
|
336
|
+
},
|
|
337
|
+
} as const;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export type OrderEmailDeliveryStatusRecord = {
|
|
2
|
+
id: number;
|
|
3
|
+
orderId: number;
|
|
4
|
+
kind: "order-confirmation";
|
|
5
|
+
status: "pending" | "processing" | "sent" | "failed";
|
|
6
|
+
attemptCount: number;
|
|
7
|
+
nextAttemptAt: Date;
|
|
8
|
+
sentAt: Date | null;
|
|
9
|
+
lastErrorCode: string | null;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The admin read model is deliberately narrower than the outbox entity. It
|
|
14
|
+
* must never grow into a backdoor for recipients, encrypted content, worker
|
|
15
|
+
* leases, or checkout confirmation tokens.
|
|
16
|
+
*/
|
|
17
|
+
export function toOrderEmailDeliveryStatus(
|
|
18
|
+
delivery: OrderEmailDeliveryStatusRecord,
|
|
19
|
+
) {
|
|
20
|
+
return {
|
|
21
|
+
deliveryId: delivery.id,
|
|
22
|
+
orderId: delivery.orderId,
|
|
23
|
+
kind: delivery.kind,
|
|
24
|
+
status: delivery.status,
|
|
25
|
+
attemptCount: delivery.attemptCount,
|
|
26
|
+
nextAttemptAt: delivery.nextAttemptAt,
|
|
27
|
+
sentAt: delivery.sentAt,
|
|
28
|
+
lastErrorCode: delivery.lastErrorCode,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function isOrderAvailableInChannel(
|
|
33
|
+
orderChannelIds: readonly (number | string)[],
|
|
34
|
+
activeChannelId: number | string,
|
|
35
|
+
): boolean {
|
|
36
|
+
return orderChannelIds.some(
|
|
37
|
+
(orderChannelId) => String(orderChannelId) === String(activeChannelId),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Column,
|
|
3
|
+
CreateDateColumn,
|
|
4
|
+
Entity,
|
|
5
|
+
Index,
|
|
6
|
+
PrimaryGeneratedColumn,
|
|
7
|
+
Unique,
|
|
8
|
+
UpdateDateColumn,
|
|
9
|
+
} from "typeorm";
|
|
10
|
+
|
|
11
|
+
export type OrderEmailDeliveryStatus =
|
|
12
|
+
"pending" | "processing" | "sent" | "failed";
|
|
13
|
+
|
|
14
|
+
@Entity({ name: "jarshop_order_email_delivery" })
|
|
15
|
+
@Unique("UQ_jarshop_order_email_delivery_order_kind", ["orderId", "kind"])
|
|
16
|
+
@Index("IDX_jarshop_order_email_delivery_pending", ["status", "nextAttemptAt"])
|
|
17
|
+
export class JarShopOrderEmailDelivery {
|
|
18
|
+
@PrimaryGeneratedColumn()
|
|
19
|
+
id!: number;
|
|
20
|
+
|
|
21
|
+
@Column({ type: "int" })
|
|
22
|
+
orderId!: number;
|
|
23
|
+
|
|
24
|
+
@Column({ type: "varchar", length: 36 })
|
|
25
|
+
checkoutAttemptId!: string;
|
|
26
|
+
|
|
27
|
+
@Column({ type: "varchar", length: 64 })
|
|
28
|
+
kind!: "order-confirmation";
|
|
29
|
+
|
|
30
|
+
@Column({ type: "varchar", length: 10 })
|
|
31
|
+
languageCode!: string;
|
|
32
|
+
|
|
33
|
+
@Column({ type: "varchar", length: 16 })
|
|
34
|
+
status!: OrderEmailDeliveryStatus;
|
|
35
|
+
|
|
36
|
+
@Column({ type: "text" })
|
|
37
|
+
payloadCiphertext!: string;
|
|
38
|
+
|
|
39
|
+
@Column({ type: "int", default: 0 })
|
|
40
|
+
attemptCount!: number;
|
|
41
|
+
|
|
42
|
+
@Column({ type: "timestamptz" })
|
|
43
|
+
nextAttemptAt!: Date;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A temporary worker ownership fence. The token prevents a worker whose
|
|
47
|
+
* lease expired from completing a delivery claimed by a newer worker.
|
|
48
|
+
*/
|
|
49
|
+
@Column({ type: "varchar", length: 36, nullable: true })
|
|
50
|
+
leaseToken!: string | null;
|
|
51
|
+
|
|
52
|
+
@Column({ type: "timestamptz", nullable: true })
|
|
53
|
+
leaseExpiresAt!: Date | null;
|
|
54
|
+
|
|
55
|
+
@Column({ type: "varchar", length: 64, nullable: true })
|
|
56
|
+
lastErrorCode!: string | null;
|
|
57
|
+
|
|
58
|
+
@Column({ type: "timestamptz", nullable: true })
|
|
59
|
+
sentAt!: Date | null;
|
|
60
|
+
|
|
61
|
+
@CreateDateColumn()
|
|
62
|
+
createdAt!: Date;
|
|
63
|
+
|
|
64
|
+
@UpdateDateColumn()
|
|
65
|
+
updatedAt!: Date;
|
|
66
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const encryptionAlgorithm = "aes-256-gcm";
|
|
4
|
+
const encryptionKeyBytes = 32;
|
|
5
|
+
|
|
6
|
+
export type OrderEmailOutboxPayload = {
|
|
7
|
+
confirmationToken: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function assertOrderEmailOutboxEncryptionConfiguration(): void {
|
|
11
|
+
parseOrderEmailEncryptionKey(process.env.JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPendingOrderEmailDelivery(input: {
|
|
15
|
+
orderId: number;
|
|
16
|
+
checkoutAttemptId: string;
|
|
17
|
+
languageCode: string;
|
|
18
|
+
confirmationToken: string;
|
|
19
|
+
now?: Date;
|
|
20
|
+
encodedKey?: string;
|
|
21
|
+
}) {
|
|
22
|
+
return {
|
|
23
|
+
orderId: input.orderId,
|
|
24
|
+
checkoutAttemptId: input.checkoutAttemptId,
|
|
25
|
+
kind: "order-confirmation" as const,
|
|
26
|
+
languageCode: input.languageCode,
|
|
27
|
+
status: "pending" as const,
|
|
28
|
+
payloadCiphertext: encryptOrderEmailOutboxPayload(
|
|
29
|
+
{ confirmationToken: input.confirmationToken },
|
|
30
|
+
input.encodedKey,
|
|
31
|
+
),
|
|
32
|
+
attemptCount: 0,
|
|
33
|
+
nextAttemptAt: input.now ?? new Date(),
|
|
34
|
+
leaseToken: null,
|
|
35
|
+
leaseExpiresAt: null,
|
|
36
|
+
lastErrorCode: null,
|
|
37
|
+
sentAt: null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function encryptOrderEmailOutboxPayload(
|
|
42
|
+
payload: OrderEmailOutboxPayload,
|
|
43
|
+
encodedKey = process.env.JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY,
|
|
44
|
+
): string {
|
|
45
|
+
const key = parseOrderEmailEncryptionKey(encodedKey);
|
|
46
|
+
const initializationVector = randomBytes(12);
|
|
47
|
+
const cipher = createCipheriv(encryptionAlgorithm, key, initializationVector);
|
|
48
|
+
const ciphertext = Buffer.concat([
|
|
49
|
+
cipher.update(JSON.stringify(payload), "utf8"),
|
|
50
|
+
cipher.final(),
|
|
51
|
+
]);
|
|
52
|
+
const tag = cipher.getAuthTag();
|
|
53
|
+
return [
|
|
54
|
+
"v1",
|
|
55
|
+
initializationVector.toString("base64url"),
|
|
56
|
+
tag.toString("base64url"),
|
|
57
|
+
ciphertext.toString("base64url"),
|
|
58
|
+
].join(".");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function decryptOrderEmailOutboxPayload(
|
|
62
|
+
payloadCiphertext: string,
|
|
63
|
+
encodedKey = process.env.JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY,
|
|
64
|
+
): OrderEmailOutboxPayload {
|
|
65
|
+
const [version, initializationVector, tag, ciphertext, ...unexpected] =
|
|
66
|
+
payloadCiphertext.split(".");
|
|
67
|
+
if (
|
|
68
|
+
version !== "v1" ||
|
|
69
|
+
!initializationVector ||
|
|
70
|
+
!tag ||
|
|
71
|
+
!ciphertext ||
|
|
72
|
+
unexpected.length > 0
|
|
73
|
+
) {
|
|
74
|
+
throw new Error("order email outbox payload has an unsupported format");
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const decipher = createDecipheriv(
|
|
78
|
+
encryptionAlgorithm,
|
|
79
|
+
parseOrderEmailEncryptionKey(encodedKey),
|
|
80
|
+
Buffer.from(initializationVector, "base64url"),
|
|
81
|
+
);
|
|
82
|
+
decipher.setAuthTag(Buffer.from(tag, "base64url"));
|
|
83
|
+
const decoded = Buffer.concat([
|
|
84
|
+
decipher.update(Buffer.from(ciphertext, "base64url")),
|
|
85
|
+
decipher.final(),
|
|
86
|
+
]).toString("utf8");
|
|
87
|
+
const payload = JSON.parse(decoded) as Partial<OrderEmailOutboxPayload>;
|
|
88
|
+
if (
|
|
89
|
+
typeof payload.confirmationToken !== "string" ||
|
|
90
|
+
!payload.confirmationToken
|
|
91
|
+
) {
|
|
92
|
+
throw new Error("missing confirmation token");
|
|
93
|
+
}
|
|
94
|
+
return { confirmationToken: payload.confirmationToken };
|
|
95
|
+
} catch {
|
|
96
|
+
throw new Error("order email outbox payload cannot be decrypted");
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function parseOrderEmailEncryptionKey(encodedKey: string | undefined) {
|
|
101
|
+
const key = encodedKey ? Buffer.from(encodedKey, "base64") : Buffer.alloc(0);
|
|
102
|
+
if (key.length !== encryptionKeyBytes) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
"JARSHOP_ORDER_EMAIL_ENCRYPTION_KEY must be a base64-encoded 32-byte key",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
return key;
|
|
108
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import nodemailer from "nodemailer";
|
|
2
|
+
import {
|
|
3
|
+
createFileOrderEmailTransport,
|
|
4
|
+
createSmtpOrderEmailTransport,
|
|
5
|
+
readOrderEmailTransportConfiguration,
|
|
6
|
+
type OrderEmailTransport,
|
|
7
|
+
} from "./order-email-delivery-core.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Builds the environment-selected delivery implementation without sending or
|
|
11
|
+
* probing the SMTP provider. The worker owns the first actual delivery call.
|
|
12
|
+
*/
|
|
13
|
+
export function createConfiguredOrderEmailTransport(
|
|
14
|
+
environment = process.env,
|
|
15
|
+
): OrderEmailTransport {
|
|
16
|
+
const configuration = readOrderEmailTransportConfiguration(environment);
|
|
17
|
+
if (configuration.kind === "file") {
|
|
18
|
+
return createFileOrderEmailTransport({
|
|
19
|
+
outputPath: configuration.outputPath,
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const client = nodemailer.createTransport({
|
|
24
|
+
host: configuration.host,
|
|
25
|
+
port: configuration.port,
|
|
26
|
+
secure: configuration.secure,
|
|
27
|
+
auth: {
|
|
28
|
+
user: configuration.username,
|
|
29
|
+
pass: configuration.password,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
return createSmtpOrderEmailTransport(configuration, async (message) => {
|
|
33
|
+
await client.sendMail(message);
|
|
34
|
+
});
|
|
35
|
+
}
|