@webamoki/web-svelte 1.2.2 → 2.0.0
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 +3 -1
- package/dist/components/showcase/CodeBlock.svelte +1 -1
- package/dist/{components → shared/components}/form/Button.svelte +2 -2
- package/dist/{components → shared/components}/form/Button.svelte.d.ts +2 -2
- package/dist/{components → shared/components}/form/Errors.svelte +1 -1
- package/dist/{components → shared/components}/form/FieldWrapper.svelte +1 -1
- package/dist/{components → shared/components}/form/fields/ChoiceMultiField.svelte +3 -1
- package/dist/{components → shared/components}/form/fields/DateField.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/MessageField.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/NumberField.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/PasswordField.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/SelectField.svelte +3 -3
- package/dist/{components → shared/components}/form/fields/SelectMultiField.svelte +3 -3
- package/dist/{components → shared/components}/form/fields/TextField.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/TextFieldNullable.svelte +2 -2
- package/dist/{components → shared/components}/form/fields/TimeField.svelte +2 -2
- package/dist/{components → shared/components}/ui/choice/ChoiceInternal.svelte +1 -1
- package/dist/{components → shared/components}/ui/choice/WeekdayChoice.svelte +6 -3
- package/dist/{components → shared/components}/ui/choice/WeekdayChoice.svelte.d.ts +1 -1
- package/dist/{components → shared/components}/ui/choice/WeekdayChoiceMulti.svelte +6 -3
- package/dist/{components → shared/components}/ui/choice/WeekdayChoiceMulti.svelte.d.ts +1 -1
- package/dist/{components → shared/components}/ui/context-menu/ContextMenuContent.svelte +1 -1
- package/dist/{components → shared/components}/ui/context-menu/ContextMenuItem.svelte +1 -1
- package/dist/{components → shared/components}/ui/search/SearchBar.svelte +2 -2
- package/dist/{utils/types → shared/utils}/arktype.d.ts +4 -12
- package/dist/shared/utils/arktype.js +40 -0
- package/dist/shared/utils/datetime/datetime.spec.d.ts +1 -0
- package/dist/shared/utils/datetime/datetime.spec.js +768 -0
- package/dist/{utils → shared/utils}/datetime/index.d.ts +22 -14
- package/dist/{utils → shared/utils}/datetime/index.js +44 -32
- package/dist/{utils → shared/utils}/email/README.md +5 -5
- package/dist/{utils → shared/utils}/email/ses.js +17 -9
- package/dist/shared/utils/email/ses.test.d.ts +1 -0
- package/dist/shared/utils/email/ses.test.js +335 -0
- package/dist/shared/utils/functional/index.d.ts +2 -0
- package/dist/shared/utils/functional/index.js +2 -0
- package/dist/shared/utils/functional/result.d.ts +72 -0
- package/dist/shared/utils/functional/result.js +86 -0
- package/dist/shared/utils/functional/result.spec.d.ts +1 -0
- package/dist/shared/utils/functional/result.spec.js +96 -0
- package/dist/shared/utils/remote.d.ts +28 -0
- package/dist/shared/utils/remote.js +74 -0
- package/dist/{utils/search.d.ts → shared/utils/string.d.ts} +1 -0
- package/dist/{utils/search.js → shared/utils/string.js} +13 -4
- package/package.json +28 -33
- package/dist/utils/email/aws-signer.d.ts +0 -17
- package/dist/utils/email/aws-signer.js +0 -83
- package/dist/utils/string.d.ts +0 -1
- package/dist/utils/string.js +0 -4
- package/dist/utils/types/arktype.js +0 -92
- package/dist/utils/types/consts.d.ts +0 -5
- package/dist/utils/types/consts.js +0 -5
- package/dist/utils/types/db.d.ts +0 -57
- package/dist/utils/types/db.js +0 -34
- /package/dist/{components → shared/components}/form/Errors.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/FieldWrapper.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/Form.svelte +0 -0
- /package/dist/{components → shared/components}/form/Form.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/IconInputWrapper.svelte +0 -0
- /package/dist/{components → shared/components}/form/IconInputWrapper.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/ChoiceField.svelte +0 -0
- /package/dist/{components → shared/components}/form/fields/ChoiceField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/ChoiceMultiField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/DateField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/HexColorField.svelte +0 -0
- /package/dist/{components → shared/components}/form/fields/HexColorField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/MessageField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/NumberField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/PasswordField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/SelectField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/SelectMultiField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/TextField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/TextFieldNullable.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/TimeField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/WeekdayChoiceField.svelte +0 -0
- /package/dist/{components → shared/components}/form/fields/WeekdayChoiceField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/fields/WeekdayChoiceMultiField.svelte +0 -0
- /package/dist/{components → shared/components}/form/fields/WeekdayChoiceMultiField.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/form/index.d.ts +0 -0
- /package/dist/{components → shared/components}/form/index.js +0 -0
- /package/dist/{components → shared/components}/ui/choice/Choice.svelte +0 -0
- /package/dist/{components → shared/components}/ui/choice/Choice.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/choice/ChoiceInternal.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/choice/ChoiceMulti.svelte +0 -0
- /package/dist/{components → shared/components}/ui/choice/ChoiceMulti.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenu.svelte +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenu.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuContent.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuItem.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuSeparator.svelte +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuSeparator.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuTrigger.svelte +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/ContextMenuTrigger.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/context-menu-state.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/context-menu/context-menu-state.svelte.js +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/Draggable.svelte +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/Draggable.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/Dropzone.svelte +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/Dropzone.svelte.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/drag-manager.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/drag-drop/drag-manager.js +0 -0
- /package/dist/{components → shared/components}/ui/index.d.ts +0 -0
- /package/dist/{components → shared/components}/ui/index.js +0 -0
- /package/dist/{components → shared/components}/ui/search/SearchBar.svelte.d.ts +0 -0
- /package/dist/{server → shared/server}/form-handler.d.ts +0 -0
- /package/dist/{server → shared/server}/form-handler.js +0 -0
- /package/dist/{server → shared/server}/form-processor.d.ts +0 -0
- /package/dist/{server → shared/server}/form-processor.js +0 -0
- /package/dist/{utils → shared/utils}/email/index.d.ts +0 -0
- /package/dist/{utils → shared/utils}/email/index.js +0 -0
- /package/dist/{utils → shared/utils}/email/ses.d.ts +0 -0
- /package/dist/{utils → shared/utils}/form/index.d.ts +0 -0
- /package/dist/{utils → shared/utils}/form/index.js +0 -0
- /package/dist/{utils → shared/utils}/form/virtual-form.d.ts +0 -0
- /package/dist/{utils → shared/utils}/form/virtual-form.js +0 -0
- /package/dist/{highlight.d.ts → utils/highlight.d.ts} +0 -0
- /package/dist/{highlight.js → utils/highlight.js} +0 -0
|
@@ -1,15 +1,29 @@
|
|
|
1
1
|
import type { Transport } from '@sveltejs/kit';
|
|
2
2
|
import { CalendarDate, type DateDuration, Time, ZonedDateTime } from '@internationalized/date';
|
|
3
|
-
import type { Day } from '../types/arktype.js';
|
|
4
3
|
export declare const Days: readonly ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
4
|
+
export type Day = (typeof Days)[number];
|
|
5
5
|
export declare const DayIndex: Record<Day, number>;
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
export declare class LocalDateF {
|
|
7
|
+
private readonly timezone;
|
|
8
|
+
constructor(timezone: string);
|
|
9
|
+
/**
|
|
10
|
+
* Calculates the age from a date of birth.
|
|
11
|
+
* @param dob - The date of birth.
|
|
12
|
+
* @returns The age in years.
|
|
13
|
+
* @throws Error if the date of birth is in the future.
|
|
14
|
+
*/
|
|
15
|
+
ageFromDob(dob: CalendarDate): number;
|
|
16
|
+
/**
|
|
17
|
+
* Checks if a given date is today.
|
|
18
|
+
* @param date - The date to check.
|
|
19
|
+
* @returns True if the date is today, false otherwise.
|
|
20
|
+
*/
|
|
21
|
+
isDateToday(date: CalendarDate): boolean;
|
|
22
|
+
/**
|
|
23
|
+
* @returns The current date.
|
|
24
|
+
*/
|
|
25
|
+
today(): CalendarDate;
|
|
26
|
+
}
|
|
13
27
|
/**
|
|
14
28
|
* Checks if two time ranges overlap, boundaries are not considered overlapping.
|
|
15
29
|
* @param start1 - The start time of the first range.
|
|
@@ -73,12 +87,6 @@ export declare function getNextDateOfDay(dayOfWeek: Day, startDate: CalendarDate
|
|
|
73
87
|
* @returns True if the date is the specified day, false otherwise.
|
|
74
88
|
*/
|
|
75
89
|
export declare function isDateDay(date: CalendarDate, dayOfWeek: Day): boolean;
|
|
76
|
-
/**
|
|
77
|
-
* Checks if a given date is today.
|
|
78
|
-
* @param date - The date to check.
|
|
79
|
-
* @returns True if the date is today, false otherwise.
|
|
80
|
-
*/
|
|
81
|
-
export declare function isDateToday(date: CalendarDate, timezone: string): boolean;
|
|
82
90
|
/**
|
|
83
91
|
* Calculates the difference in weeks between two dates.
|
|
84
92
|
* @param date1 - The first date in order.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { CalendarDate, DateFormatter, fromDate, getDayOfWeek, getLocalTimeZone, startOfMonth, Time, toCalendarDate, today, toTime, ZonedDateTime } from '@internationalized/date';
|
|
1
|
+
import { CalendarDate, DateFormatter, fromDate, getDayOfWeek, getLocalTimeZone, startOfMonth, Time, toCalendarDate, today as todayFn, toTime, ZonedDateTime } from '@internationalized/date';
|
|
2
2
|
import { map, range } from 'ramda';
|
|
3
|
-
const DEFAULT_TIME_ZONE = 'Europe/London';
|
|
4
3
|
const DEFAULT_LOCALE = 'en-GB';
|
|
5
4
|
// Day of the week
|
|
6
5
|
export const Days = [
|
|
@@ -13,25 +12,46 @@ export const Days = [
|
|
|
13
12
|
'Sunday'
|
|
14
13
|
];
|
|
15
14
|
export const DayIndex = Object.fromEntries(Days.map((day, index) => [day, index]));
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
// Date functions local to a timezone
|
|
16
|
+
export class LocalDateF {
|
|
17
|
+
timezone;
|
|
18
|
+
constructor(timezone) {
|
|
19
|
+
this.timezone = timezone;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Calculates the age from a date of birth.
|
|
23
|
+
* @param dob - The date of birth.
|
|
24
|
+
* @returns The age in years.
|
|
25
|
+
* @throws Error if the date of birth is in the future.
|
|
26
|
+
*/
|
|
27
|
+
ageFromDob(dob) {
|
|
28
|
+
const todayDate = this.today();
|
|
29
|
+
if (todayDate.compare(dob) < 0) {
|
|
30
|
+
throw new Error('Date of birth is in the future');
|
|
31
|
+
}
|
|
32
|
+
let years = todayDate.year - dob.year;
|
|
33
|
+
const monthDiff = todayDate.month - dob.month;
|
|
34
|
+
const dayDiff = todayDate.day - dob.day;
|
|
35
|
+
// Adjust years down if birthday hasn't occurred this year
|
|
36
|
+
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
|
|
37
|
+
years--;
|
|
38
|
+
}
|
|
39
|
+
return years;
|
|
26
40
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
/**
|
|
42
|
+
* Checks if a given date is today.
|
|
43
|
+
* @param date - The date to check.
|
|
44
|
+
* @returns True if the date is today, false otherwise.
|
|
45
|
+
*/
|
|
46
|
+
isDateToday(date) {
|
|
47
|
+
return this.today().compare(date) === 0;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* @returns The current date.
|
|
51
|
+
*/
|
|
52
|
+
today() {
|
|
53
|
+
return todayFn(this.timezone);
|
|
33
54
|
}
|
|
34
|
-
return years;
|
|
35
55
|
}
|
|
36
56
|
/**
|
|
37
57
|
* Checks if two time ranges overlap, boundaries are not considered overlapping.
|
|
@@ -145,14 +165,6 @@ export function isDateDay(date, dayOfWeek) {
|
|
|
145
165
|
const dateDay = getDayOfDate(date);
|
|
146
166
|
return dateDay === dayOfWeek;
|
|
147
167
|
}
|
|
148
|
-
/**
|
|
149
|
-
* Checks if a given date is today.
|
|
150
|
-
* @param date - The date to check.
|
|
151
|
-
* @returns True if the date is today, false otherwise.
|
|
152
|
-
*/
|
|
153
|
-
export function isDateToday(date, timezone) {
|
|
154
|
-
return today(timezone).compare(date) === 0;
|
|
155
|
-
}
|
|
156
168
|
const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
|
157
169
|
/**
|
|
158
170
|
* Calculates the difference in weeks between two dates.
|
|
@@ -160,9 +172,10 @@ const msPerWeek = 7 * 24 * 60 * 60 * 1000;
|
|
|
160
172
|
* @param date2 - The second date in order.
|
|
161
173
|
*/
|
|
162
174
|
export function dateDiffWeeks(date1, date2) {
|
|
163
|
-
const
|
|
164
|
-
const
|
|
165
|
-
|
|
175
|
+
const date1Ms = date1.toDate('UTC').getTime();
|
|
176
|
+
const date2Ms = date2.toDate('UTC').getTime();
|
|
177
|
+
const absDiff = Math.abs(date2Ms - date1Ms);
|
|
178
|
+
return Math.floor(absDiff / msPerWeek);
|
|
166
179
|
}
|
|
167
180
|
/* Formatting */
|
|
168
181
|
/* Day of the week*/
|
|
@@ -313,9 +326,8 @@ function formatDate(date, formatter) {
|
|
|
313
326
|
}
|
|
314
327
|
// Pad number with zeroes to the left
|
|
315
328
|
function padNum(num, len) {
|
|
316
|
-
if (isNaN(num))
|
|
329
|
+
if (isNaN(num))
|
|
317
330
|
return '0'.repeat(len);
|
|
318
|
-
}
|
|
319
331
|
return num.toString().padStart(len, '0');
|
|
320
332
|
}
|
|
321
333
|
// SerDe
|
|
@@ -4,17 +4,17 @@ This package provides utilities for sending emails using AWS Simple Email Servic
|
|
|
4
4
|
|
|
5
5
|
## Key Features
|
|
6
6
|
|
|
7
|
-
- **Cloudflare Workers Compatible**: Uses direct AWS SES API calls
|
|
8
|
-
- **AWS Signature V4**:
|
|
7
|
+
- **Cloudflare Workers Compatible**: Uses `aws4fetch` for direct AWS SES API calls
|
|
8
|
+
- **AWS Signature V4**: Handled by `aws4fetch` for secure API requests
|
|
9
9
|
- **Zero Heavy Dependencies**: No AWS SDK required, works in any JavaScript runtime with fetch support
|
|
10
10
|
|
|
11
11
|
## Installation
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The implementation uses:
|
|
14
14
|
|
|
15
|
+
- `aws4fetch` for AWS Signature V4 signing and requests
|
|
15
16
|
- Native `fetch` API for HTTP requests
|
|
16
|
-
-
|
|
17
|
-
- Native `DOMParser` for XML response parsing
|
|
17
|
+
- Native `DOMParser` for XML response parsing (optional, used for error/response parsing)
|
|
18
18
|
|
|
19
19
|
## Configuration
|
|
20
20
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { AwsClient } from 'aws4fetch';
|
|
2
2
|
/**
|
|
3
3
|
* Send an email using AWS SES API.
|
|
4
4
|
* Uses AWS Signature V4 signing and fetch API for Cloudflare Workers compatibility.
|
|
@@ -71,21 +71,29 @@ export async function sendEmail(options) {
|
|
|
71
71
|
const host = `email.${awsRegion}.amazonaws.com`;
|
|
72
72
|
const path = '/';
|
|
73
73
|
try {
|
|
74
|
-
|
|
75
|
-
const { headers } = await signRequest('POST', host, path, body, {
|
|
74
|
+
const aws = new AwsClient({
|
|
76
75
|
accessKeyId: awsAccessKeyId,
|
|
77
76
|
region: awsRegion,
|
|
78
|
-
secretAccessKey: awsSecretAccessKey
|
|
77
|
+
secretAccessKey: awsSecretAccessKey,
|
|
78
|
+
service: 'ses'
|
|
79
79
|
});
|
|
80
|
-
|
|
81
|
-
const
|
|
80
|
+
const url = `https://${host}${path}`;
|
|
81
|
+
const init = {
|
|
82
82
|
body,
|
|
83
83
|
headers: {
|
|
84
|
-
|
|
85
|
-
'Content-Length': body.length.toString(),
|
|
86
|
-
Host: host
|
|
84
|
+
'Content-Type': 'application/x-form-urlencoded'
|
|
87
85
|
},
|
|
88
86
|
method: 'POST'
|
|
87
|
+
};
|
|
88
|
+
// Sign the request and then use the global fetch.
|
|
89
|
+
// We avoid using aws.fetch() directly as it can cause issues in some test environments
|
|
90
|
+
// or when fetch is mocked/replaced after AwsClient instantiation.
|
|
91
|
+
const signedRequest = await aws.sign(url, init);
|
|
92
|
+
// Use two-argument fetch to satisfy tests that expect the second argument to be an options object
|
|
93
|
+
const response = await fetch(signedRequest.url, {
|
|
94
|
+
body: init.body,
|
|
95
|
+
headers: signedRequest.headers,
|
|
96
|
+
method: signedRequest.method
|
|
89
97
|
});
|
|
90
98
|
const responseText = await response.text();
|
|
91
99
|
if (!response.ok) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
// Mock global fetch and DOMParser
|
|
3
|
+
const mockFetch = vi.fn();
|
|
4
|
+
global.fetch = mockFetch;
|
|
5
|
+
// Mock DOMParser for XML parsing
|
|
6
|
+
const mockDOMParser = vi.fn(() => ({
|
|
7
|
+
parseFromString: vi.fn((xmlString) => {
|
|
8
|
+
// Simple mock XML parser for testing
|
|
9
|
+
const messageIdMatch = xmlString.match(/<MessageId>(.*?)<\/MessageId>/);
|
|
10
|
+
const errorCodeMatch = xmlString.match(/<Code>(.*?)<\/Code>/);
|
|
11
|
+
const errorMessageMatch = xmlString.match(/<Message>(.*?)<\/Message>/);
|
|
12
|
+
return {
|
|
13
|
+
querySelector: (selector) => {
|
|
14
|
+
if (selector === 'MessageId' && messageIdMatch) {
|
|
15
|
+
return { textContent: messageIdMatch[1] };
|
|
16
|
+
}
|
|
17
|
+
if (selector === 'Error' && (errorCodeMatch || errorMessageMatch)) {
|
|
18
|
+
return {
|
|
19
|
+
querySelector: (innerSelector) => {
|
|
20
|
+
if (innerSelector === 'Code' && errorCodeMatch) {
|
|
21
|
+
return { textContent: errorCodeMatch[1] };
|
|
22
|
+
}
|
|
23
|
+
if (innerSelector === 'Message' && errorMessageMatch) {
|
|
24
|
+
return { textContent: errorMessageMatch[1] };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
})
|
|
34
|
+
}));
|
|
35
|
+
global.DOMParser = mockDOMParser;
|
|
36
|
+
// Import after mocking
|
|
37
|
+
import { sendEmail } from './ses.js';
|
|
38
|
+
const testAwsCredentials = {
|
|
39
|
+
awsAccessKeyId: 'test-access-key',
|
|
40
|
+
awsRegion: 'us-east-1',
|
|
41
|
+
awsSecretAccessKey: 'test-secret-key'
|
|
42
|
+
};
|
|
43
|
+
describe('sendEmail', () => {
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mockFetch.mockClear();
|
|
46
|
+
});
|
|
47
|
+
it('should throw error if options is not provided', async () => {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
49
|
+
await expect(sendEmail(null)).rejects.toThrow('sendEmail: options is required');
|
|
50
|
+
});
|
|
51
|
+
it('should throw error if "to" field is missing', async () => {
|
|
52
|
+
await expect(sendEmail({
|
|
53
|
+
from: 'sender@example.com',
|
|
54
|
+
subject: 'Test',
|
|
55
|
+
text: 'Test message',
|
|
56
|
+
...testAwsCredentials
|
|
57
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
58
|
+
})).rejects.toThrow('at least one valid recipient is required');
|
|
59
|
+
});
|
|
60
|
+
it('should throw error if "to" field is empty string', async () => {
|
|
61
|
+
await expect(sendEmail({
|
|
62
|
+
from: 'sender@example.com',
|
|
63
|
+
subject: 'Test',
|
|
64
|
+
text: 'Test message',
|
|
65
|
+
to: ' ',
|
|
66
|
+
...testAwsCredentials
|
|
67
|
+
})).rejects.toThrow('at least one valid recipient is required');
|
|
68
|
+
});
|
|
69
|
+
it('should throw error if "to" field is empty array', async () => {
|
|
70
|
+
await expect(sendEmail({
|
|
71
|
+
from: 'sender@example.com',
|
|
72
|
+
subject: 'Test',
|
|
73
|
+
text: 'Test message',
|
|
74
|
+
to: [],
|
|
75
|
+
...testAwsCredentials
|
|
76
|
+
})).rejects.toThrow('at least one valid recipient is required');
|
|
77
|
+
});
|
|
78
|
+
it('should throw error if "to" field contains only empty strings', async () => {
|
|
79
|
+
await expect(sendEmail({
|
|
80
|
+
from: 'sender@example.com',
|
|
81
|
+
subject: 'Test',
|
|
82
|
+
text: 'Test message',
|
|
83
|
+
to: ['', ' ', ''],
|
|
84
|
+
...testAwsCredentials
|
|
85
|
+
})).rejects.toThrow('at least one valid recipient is required');
|
|
86
|
+
});
|
|
87
|
+
it('should filter out empty strings from "to" array and accept valid emails', async () => {
|
|
88
|
+
mockFetch.mockResolvedValue({
|
|
89
|
+
ok: true,
|
|
90
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
91
|
+
});
|
|
92
|
+
await sendEmail({
|
|
93
|
+
from: 'sender@example.com',
|
|
94
|
+
subject: 'Test',
|
|
95
|
+
text: 'Test message',
|
|
96
|
+
to: ['', 'valid@example.com', ' ', 'another@example.com'],
|
|
97
|
+
...testAwsCredentials
|
|
98
|
+
});
|
|
99
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
100
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
101
|
+
const body = fetchCall[1].body;
|
|
102
|
+
expect(body).toContain('Destination.ToAddresses.member.1=valid%40example.com');
|
|
103
|
+
expect(body).toContain('Destination.ToAddresses.member.2=another%40example.com');
|
|
104
|
+
});
|
|
105
|
+
it('should filter out empty strings from cc and bcc arrays', async () => {
|
|
106
|
+
mockFetch.mockResolvedValue({
|
|
107
|
+
ok: true,
|
|
108
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
109
|
+
});
|
|
110
|
+
await sendEmail({
|
|
111
|
+
bcc: [' ', 'bcc@example.com'],
|
|
112
|
+
cc: ['', 'cc@example.com', ' '],
|
|
113
|
+
from: 'sender@example.com',
|
|
114
|
+
subject: 'Test',
|
|
115
|
+
text: 'Test message',
|
|
116
|
+
to: 'test@example.com',
|
|
117
|
+
...testAwsCredentials
|
|
118
|
+
});
|
|
119
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
120
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
121
|
+
const body = fetchCall[1].body;
|
|
122
|
+
expect(body).toContain('Destination.CcAddresses.member.1=cc%40example.com');
|
|
123
|
+
expect(body).toContain('Destination.BccAddresses.member.1=bcc%40example.com');
|
|
124
|
+
});
|
|
125
|
+
it('should omit cc/bcc if all values are empty strings', async () => {
|
|
126
|
+
mockFetch.mockResolvedValue({
|
|
127
|
+
ok: true,
|
|
128
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
129
|
+
});
|
|
130
|
+
await sendEmail({
|
|
131
|
+
bcc: [' '],
|
|
132
|
+
cc: ['', ' '],
|
|
133
|
+
from: 'sender@example.com',
|
|
134
|
+
subject: 'Test',
|
|
135
|
+
text: 'Test message',
|
|
136
|
+
to: 'test@example.com',
|
|
137
|
+
...testAwsCredentials
|
|
138
|
+
});
|
|
139
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
140
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
141
|
+
const body = fetchCall[1].body;
|
|
142
|
+
expect(body).not.toContain('Destination.CcAddresses');
|
|
143
|
+
expect(body).not.toContain('Destination.BccAddresses');
|
|
144
|
+
});
|
|
145
|
+
it('should throw error if subject is missing', async () => {
|
|
146
|
+
await expect(sendEmail({
|
|
147
|
+
text: 'Test message',
|
|
148
|
+
to: 'test@example.com',
|
|
149
|
+
...testAwsCredentials
|
|
150
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
151
|
+
})).rejects.toThrow('subject is required');
|
|
152
|
+
});
|
|
153
|
+
it('should throw error if both text and html are missing', async () => {
|
|
154
|
+
await expect(sendEmail({
|
|
155
|
+
subject: 'Test',
|
|
156
|
+
to: 'test@example.com',
|
|
157
|
+
...testAwsCredentials
|
|
158
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
159
|
+
})).rejects.toThrow('at least one of text or html body must be provided');
|
|
160
|
+
});
|
|
161
|
+
it('should throw error if from is not provided', async () => {
|
|
162
|
+
await expect(sendEmail({
|
|
163
|
+
subject: 'Test',
|
|
164
|
+
text: 'Test message',
|
|
165
|
+
to: 'test@example.com',
|
|
166
|
+
...testAwsCredentials
|
|
167
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
168
|
+
})).rejects.toThrow('sender `from` is required');
|
|
169
|
+
});
|
|
170
|
+
it('should accept single recipient as string', async () => {
|
|
171
|
+
mockFetch.mockResolvedValue({
|
|
172
|
+
ok: true,
|
|
173
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
174
|
+
});
|
|
175
|
+
await sendEmail({
|
|
176
|
+
from: 'sender@example.com',
|
|
177
|
+
subject: 'Test',
|
|
178
|
+
text: 'Test message',
|
|
179
|
+
to: 'test@example.com',
|
|
180
|
+
...testAwsCredentials
|
|
181
|
+
});
|
|
182
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
183
|
+
});
|
|
184
|
+
it('should accept multiple recipients as array', async () => {
|
|
185
|
+
mockFetch.mockResolvedValue({
|
|
186
|
+
ok: true,
|
|
187
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
188
|
+
});
|
|
189
|
+
await sendEmail({
|
|
190
|
+
from: 'sender@example.com',
|
|
191
|
+
subject: 'Test',
|
|
192
|
+
text: 'Test message',
|
|
193
|
+
to: ['test1@example.com', 'test2@example.com'],
|
|
194
|
+
...testAwsCredentials
|
|
195
|
+
});
|
|
196
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
197
|
+
});
|
|
198
|
+
it('should format source with fromName when provided', async () => {
|
|
199
|
+
mockFetch.mockResolvedValue({
|
|
200
|
+
ok: true,
|
|
201
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
202
|
+
});
|
|
203
|
+
await sendEmail({
|
|
204
|
+
from: 'sender@example.com',
|
|
205
|
+
fromName: 'Test Sender',
|
|
206
|
+
subject: 'Test',
|
|
207
|
+
text: 'Test message',
|
|
208
|
+
to: 'test@example.com',
|
|
209
|
+
...testAwsCredentials
|
|
210
|
+
});
|
|
211
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
212
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
213
|
+
const body = fetchCall[1].body;
|
|
214
|
+
expect(body).toContain('Source=Test+Sender+%3Csender%40example.com%3E');
|
|
215
|
+
});
|
|
216
|
+
it('should handle CC and BCC recipients', async () => {
|
|
217
|
+
mockFetch.mockResolvedValue({
|
|
218
|
+
ok: true,
|
|
219
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
220
|
+
});
|
|
221
|
+
await sendEmail({
|
|
222
|
+
bcc: ['bcc1@example.com', 'bcc2@example.com'],
|
|
223
|
+
cc: 'cc@example.com',
|
|
224
|
+
from: 'sender@example.com',
|
|
225
|
+
subject: 'Test',
|
|
226
|
+
text: 'Test message',
|
|
227
|
+
to: 'test@example.com',
|
|
228
|
+
...testAwsCredentials
|
|
229
|
+
});
|
|
230
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
231
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
232
|
+
const body = fetchCall[1].body;
|
|
233
|
+
expect(body).toContain('Destination.CcAddresses.member.1=cc%40example.com');
|
|
234
|
+
expect(body).toContain('Destination.BccAddresses.member.1=bcc1%40example.com');
|
|
235
|
+
expect(body).toContain('Destination.BccAddresses.member.2=bcc2%40example.com');
|
|
236
|
+
});
|
|
237
|
+
it('should include both HTML and text body when provided', async () => {
|
|
238
|
+
mockFetch.mockResolvedValue({
|
|
239
|
+
ok: true,
|
|
240
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
241
|
+
});
|
|
242
|
+
await sendEmail({
|
|
243
|
+
from: 'sender@example.com',
|
|
244
|
+
html: '<p>HTML version</p>',
|
|
245
|
+
subject: 'Test',
|
|
246
|
+
text: 'Plain text version',
|
|
247
|
+
to: 'test@example.com',
|
|
248
|
+
...testAwsCredentials
|
|
249
|
+
});
|
|
250
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
251
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
252
|
+
const body = fetchCall[1].body;
|
|
253
|
+
expect(body).toContain('Message.Body.Text.Data=Plain+text+version');
|
|
254
|
+
expect(body).toContain('Message.Body.Html.Data=%3Cp%3EHTML+version%3C%2Fp%3E');
|
|
255
|
+
});
|
|
256
|
+
it('should return MessageId on success', async () => {
|
|
257
|
+
mockFetch.mockResolvedValue({
|
|
258
|
+
ok: true,
|
|
259
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id-123</MessageId></SendEmailResponse>'
|
|
260
|
+
});
|
|
261
|
+
const result = await sendEmail({
|
|
262
|
+
from: 'sender@example.com',
|
|
263
|
+
subject: 'Test',
|
|
264
|
+
text: 'Test message',
|
|
265
|
+
to: 'test@example.com',
|
|
266
|
+
...testAwsCredentials
|
|
267
|
+
});
|
|
268
|
+
expect(result).toBe('test-message-id-123');
|
|
269
|
+
});
|
|
270
|
+
it('should throw error when SES response is missing MessageId', async () => {
|
|
271
|
+
mockFetch.mockResolvedValue({
|
|
272
|
+
ok: true,
|
|
273
|
+
text: async () => '<SendEmailResponse></SendEmailResponse>'
|
|
274
|
+
});
|
|
275
|
+
await expect(sendEmail({
|
|
276
|
+
from: 'sender@example.com',
|
|
277
|
+
subject: 'Test',
|
|
278
|
+
text: 'Test message',
|
|
279
|
+
to: 'test@example.com',
|
|
280
|
+
...testAwsCredentials
|
|
281
|
+
})).rejects.toThrow('SES response did not contain a MessageId');
|
|
282
|
+
});
|
|
283
|
+
it('should handle SES errors gracefully', async () => {
|
|
284
|
+
mockFetch.mockResolvedValue({
|
|
285
|
+
ok: false,
|
|
286
|
+
status: 400,
|
|
287
|
+
statusText: 'Bad Request',
|
|
288
|
+
text: async () => '<ErrorResponse><Error><Code>MessageRejected</Code><Message>Email address is not verified</Message></Error></ErrorResponse>'
|
|
289
|
+
});
|
|
290
|
+
await expect(sendEmail({
|
|
291
|
+
from: 'sender@example.com',
|
|
292
|
+
subject: 'Test',
|
|
293
|
+
text: 'Test message',
|
|
294
|
+
to: 'test@example.com',
|
|
295
|
+
...testAwsCredentials
|
|
296
|
+
})).rejects.toThrow(/failed to send email/);
|
|
297
|
+
});
|
|
298
|
+
it('should handle reply-to addresses', async () => {
|
|
299
|
+
mockFetch.mockResolvedValue({
|
|
300
|
+
ok: true,
|
|
301
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
302
|
+
});
|
|
303
|
+
await sendEmail({
|
|
304
|
+
from: 'sender@example.com',
|
|
305
|
+
replyTo: 'reply@example.com',
|
|
306
|
+
subject: 'Test',
|
|
307
|
+
text: 'Test message',
|
|
308
|
+
to: 'test@example.com',
|
|
309
|
+
...testAwsCredentials
|
|
310
|
+
});
|
|
311
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
312
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
313
|
+
const body = fetchCall[1].body;
|
|
314
|
+
expect(body).toContain('ReplyToAddresses.member.1=reply%40example.com');
|
|
315
|
+
});
|
|
316
|
+
it('should handle multiple reply-to addresses', async () => {
|
|
317
|
+
mockFetch.mockResolvedValue({
|
|
318
|
+
ok: true,
|
|
319
|
+
text: async () => '<SendEmailResponse><MessageId>test-message-id</MessageId></SendEmailResponse>'
|
|
320
|
+
});
|
|
321
|
+
await sendEmail({
|
|
322
|
+
from: 'sender@example.com',
|
|
323
|
+
replyTo: ['reply1@example.com', 'reply2@example.com'],
|
|
324
|
+
subject: 'Test',
|
|
325
|
+
text: 'Test message',
|
|
326
|
+
to: 'test@example.com',
|
|
327
|
+
...testAwsCredentials
|
|
328
|
+
});
|
|
329
|
+
expect(mockFetch).toHaveBeenCalledTimes(1);
|
|
330
|
+
const fetchCall = mockFetch.mock.calls[0];
|
|
331
|
+
const body = fetchCall[1].body;
|
|
332
|
+
expect(body).toContain('ReplyToAddresses.member.1=reply1%40example.com');
|
|
333
|
+
expect(body).toContain('ReplyToAddresses.member.2=reply2%40example.com');
|
|
334
|
+
});
|
|
335
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export type Result<T, E> = {
|
|
2
|
+
error: E;
|
|
3
|
+
ok: false;
|
|
4
|
+
} | {
|
|
5
|
+
ok: true;
|
|
6
|
+
value: T;
|
|
7
|
+
};
|
|
8
|
+
export declare const Result: {
|
|
9
|
+
/**
|
|
10
|
+
* Creates a failed result with the given error.
|
|
11
|
+
*/
|
|
12
|
+
err<E>(error: E): {
|
|
13
|
+
error: E;
|
|
14
|
+
ok: false;
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Creates a successful result with the given value.
|
|
18
|
+
*/
|
|
19
|
+
ok<T>(value: T): {
|
|
20
|
+
ok: true;
|
|
21
|
+
value: T;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Returns true if the result is Ok.
|
|
25
|
+
*/
|
|
26
|
+
isOk<T, E>(result: Result<T, E>): result is {
|
|
27
|
+
ok: true;
|
|
28
|
+
value: T;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Returns true if the result is Err.
|
|
32
|
+
*/
|
|
33
|
+
isErr<T, E>(result: Result<T, E>): result is {
|
|
34
|
+
error: E;
|
|
35
|
+
ok: false;
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Returns the contained Ok value or a provided default.
|
|
39
|
+
*/
|
|
40
|
+
unwrapOr<T, E>(result: Result<T, E>, defaultValue: T): T;
|
|
41
|
+
/**
|
|
42
|
+
* Returns the contained Ok value or computes it from the provided op.
|
|
43
|
+
* @param op - Computes value in error case or throws an error.
|
|
44
|
+
*/
|
|
45
|
+
unwrapOrElse<T, E>(result: Result<T, E>, op: (error: E) => T): T;
|
|
46
|
+
/**
|
|
47
|
+
* Maps a Result<T, E> to Result<U, E> by applying a function to a contained Ok value,
|
|
48
|
+
* leaving an Err value untouched.
|
|
49
|
+
*/
|
|
50
|
+
map<T, U, E>(result: Result<T, E>, fn: (val: T) => U): Result<U, E>;
|
|
51
|
+
/**
|
|
52
|
+
* Maps a Result<T, E> to Result<T, F> by applying a function to a contained Err value,
|
|
53
|
+
* leaving an Ok value untouched.
|
|
54
|
+
*/
|
|
55
|
+
mapErr<T, E, F>(result: Result<T, E>, fn: (err: E) => F): Result<T, F>;
|
|
56
|
+
/**
|
|
57
|
+
* Calls op if the result is Ok, otherwise returns the Err value of self.
|
|
58
|
+
* This function can be used for control flow based on Result values.
|
|
59
|
+
*/
|
|
60
|
+
andThen<T, U, E>(result: Result<T, E>, op: (val: T) => Result<U, E>): Result<U, E>;
|
|
61
|
+
/**
|
|
62
|
+
* Calls op if the result is Err, otherwise returns the Ok value of self.
|
|
63
|
+
*/
|
|
64
|
+
orElse<T, E, F>(result: Result<T, E>, op: (err: E) => Result<T, F>): Result<T, F>;
|
|
65
|
+
/**
|
|
66
|
+
* Pattern matches the Result and returns the result of the corresponding arm.
|
|
67
|
+
*/
|
|
68
|
+
match<T, E, R>(result: Result<T, E>, arms: {
|
|
69
|
+
err: (err: E) => R;
|
|
70
|
+
ok: (val: T) => R;
|
|
71
|
+
}): R;
|
|
72
|
+
};
|