@cedarjs/mailer-handler-ahasend 7.0.0-canary.3261

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 ADDED
@@ -0,0 +1,122 @@
1
+ # Mailer - Handler - AhaSend
2
+
3
+ ## Prerequisites
4
+
5
+ We assume you have the basic boilerplate for Cedar Mailer present. We also
6
+ assume that you have signed up with [AhaSend](https://ahasend.com/), verified a
7
+ sending domain, and have an API key with permission to send messages, along with
8
+ your account ID.
9
+
10
+ ## Setup
11
+
12
+ We should install this handler package as a production dependency of the API
13
+ side. We can do this with the following command:
14
+
15
+ ```bash
16
+ yarn workspace api add @cedarjs/mailer-handler-ahasend
17
+ ```
18
+
19
+ After this you should be able to import this handler into your
20
+ `api/src/lib/mailer.ts` file and create an instance of this handler with your
21
+ API key and account ID.
22
+
23
+ ```typescript
24
+ import { AhaSendMailHandler } from '@cedarjs/mailer-handler-ahasend'
25
+
26
+ // ...
27
+
28
+ export const mailer = new Mailer({
29
+ handling: {
30
+ handlers: {
31
+ // ...
32
+ ahasend: new AhaSendMailHandler({
33
+ apiKey: process.env.AHASEND_API_KEY,
34
+ accountId: process.env.AHASEND_ACCOUNT_ID,
35
+ }),
36
+ },
37
+ // ...
38
+ },
39
+ // ...
40
+ })
41
+ ```
42
+
43
+ The handler accepts every option of the `AhaSendClient` from
44
+ [`@ahasend/sdk`](https://github.com/AhaSend/ahasend-ts), such as `timeoutMs` and
45
+ `retry`. By default the client retries failed requests up to three times and
46
+ reuses one idempotency key across those retries. AhaSend stores the outcome of
47
+ every request it accepts or rejects for 24 hours and replays it when a retry
48
+ uses the same key, so such a retry does not queue the email again. Server
49
+ errors are not stored, so a retry after a server error can still deliver the
50
+ email twice.
51
+
52
+ If you need access to the underlying AhaSend client to perform more specific
53
+ behavior the SDK exposes you can always access this using the `internal`
54
+ function on this AhaSend handler.
55
+
56
+ ```typescript
57
+ const ahasendHandler = mailer.handlers.ahasend
58
+ const ahasendClient = ahasendHandler.internal().client
59
+ ```
60
+
61
+ ## Usage
62
+
63
+ You should be able to use this newly configured handler like any other previous
64
+ handler and it should require no changes to your mailer code.
65
+
66
+ Each email is sent as a single message that all `to`, `cc` and `bcc` recipients
67
+ share, with `bcc` recipients hidden from the others. AhaSend allows at most 50
68
+ recipients per email.
69
+
70
+ You can pass AhaSend specific options as the third argument to `mailer.send()`:
71
+
72
+ ```typescript
73
+ await mailer.send(
74
+ WelcomeEmail({ name: user.name }),
75
+ { to: user.email, subject: 'Welcome!' },
76
+ {
77
+ tags: ['welcome'],
78
+ idempotencyKey: `welcome-${user.id}`,
79
+ },
80
+ )
81
+ ```
82
+
83
+ The available options are `tags`, `sandbox`, `sandbox_result`, `tracking`,
84
+ `retention`, `schedule` and `idempotencyKey`. See the
85
+ [AhaSend API reference](https://ahasend.com/docs/api-reference) for what each of
86
+ them does. `sandbox: true` is useful for testing your setup, because AhaSend
87
+ accepts the email without delivering it.
88
+
89
+ ### Attachments
90
+
91
+ Every attachment needs a filename, either set as `filename` or taken from
92
+ `path`. The attachment's content type is derived from the file extension. String
93
+ `content` is sent as UTF-8 text, and `Buffer` content is sent as binary data. A
94
+ `path` can be a local file or an `http(s)` URL.
95
+
96
+ ## Error Handling
97
+
98
+ `mailer.send()` rejects when AhaSend does not accept the email, for example
99
+ because of an invalid API key, an unverified sender domain, or a network failure
100
+ that persists through the client's retries. The error from the AhaSend SDK is
101
+ available as the `cause` of the thrown error.
102
+
103
+ AhaSend reports a result for each recipient. `mailer.send()` also rejects when
104
+ AhaSend accepts none of the recipients, with the per-recipient results as the
105
+ `cause` of the thrown error.
106
+
107
+ When AhaSend accepts some recipients and rejects others, for example because
108
+ they are on your suppression list, `mailer.send()` resolves. The email has
109
+ already been queued for the accepted recipients, so retrying the send would
110
+ deliver it to them a second time. The result's `messageID` is the ID of the
111
+ first accepted recipient's message, and `handlerInformation` contains the result
112
+ for every recipient:
113
+
114
+ ```typescript
115
+ const result = await mailer.send(/* ... */)
116
+
117
+ // handlerInformation is typed as unknown by the mailer, so narrow it first
118
+ const recipients = (result.handlerInformation as SendMessageResponse).data
119
+ const rejected = recipients.filter((recipient) => recipient.status === 'error')
120
+ ```
121
+
122
+ `SendMessageResponse` is exported by `@ahasend/sdk`.
@@ -0,0 +1,25 @@
1
+ import { AhaSendClient } from '@ahasend/sdk';
2
+ import type { AhaSendClientOptions, CreateConversationMessageRequest } from '@ahasend/sdk';
3
+ import type { MailSendOptionsComplete, MailRenderedContent, MailResult } from '@cedarjs/mailer-core';
4
+ import { AbstractMailHandler } from '@cedarjs/mailer-core';
5
+ export type AhaSendMailHandlerOptions = Pick<CreateConversationMessageRequest, 'tags' | 'sandbox' | 'sandbox_result' | 'tracking' | 'retention' | 'schedule'> & {
6
+ /**
7
+ * Idempotency key for this send. AhaSend replays the stored result for
8
+ * repeated requests with the same key for 24 hours. When omitted, the SDK
9
+ * generates a key that is reused across its own retries of this request.
10
+ */
11
+ idempotencyKey?: string;
12
+ };
13
+ export declare class AhaSendMailHandler extends AbstractMailHandler {
14
+ private client;
15
+ /**
16
+ * Accepts every `AhaSendClient` option. `apiKey` and `accountId` are
17
+ * required; the others, such as `retry` and `timeoutMs`, are optional.
18
+ */
19
+ constructor(options: AhaSendClientOptions);
20
+ send(content: MailRenderedContent, sendOptions: MailSendOptionsComplete, handlerOptions?: AhaSendMailHandlerOptions): Promise<MailResult>;
21
+ internal(): {
22
+ client: AhaSendClient;
23
+ };
24
+ }
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,aAAa,EAAE,MAAM,cAAc,CAAA;AAC5C,OAAO,KAAK,EAEV,oBAAoB,EAEpB,gCAAgC,EAEjC,MAAM,cAAc,CAAA;AAErB,OAAO,KAAK,EAEV,uBAAuB,EACvB,mBAAmB,EACnB,UAAU,EACX,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAE,mBAAmB,EAAE,MAAM,sBAAsB,CAAA;AAE1D,MAAM,MAAM,yBAAyB,GAAG,IAAI,CAC1C,gCAAgC,EAChC,MAAM,GAAG,SAAS,GAAG,gBAAgB,GAAG,UAAU,GAAG,WAAW,GAAG,UAAU,CAC9E,GAAG;IACF;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,CAAA;AAmHD,qBAAa,kBAAmB,SAAQ,mBAAmB;IACzD,OAAO,CAAC,MAAM,CAAe;IAE7B;;;OAGG;gBACS,OAAO,EAAE,oBAAoB;IAKnC,IAAI,CACR,OAAO,EAAE,mBAAmB,EAC5B,WAAW,EAAE,uBAAuB,EACpC,cAAc,CAAC,EAAE,yBAAyB,GACzC,OAAO,CAAC,UAAU,CAAC;IAqEtB,QAAQ;;;CAKT"}
package/dist/index.js ADDED
@@ -0,0 +1,142 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { AhaSendClient } from "@ahasend/sdk";
4
+ import { AbstractMailHandler } from "@cedarjs/mailer-core";
5
+ const contentTypesByExtension = {
6
+ ".csv": "text/csv",
7
+ ".gif": "image/gif",
8
+ ".htm": "text/html",
9
+ ".html": "text/html",
10
+ ".ics": "text/calendar",
11
+ ".jpeg": "image/jpeg",
12
+ ".jpg": "image/jpeg",
13
+ ".json": "application/json",
14
+ ".pdf": "application/pdf",
15
+ ".png": "image/png",
16
+ ".svg": "image/svg+xml",
17
+ ".txt": "text/plain",
18
+ ".webp": "image/webp",
19
+ ".xml": "application/xml",
20
+ ".zip": "application/zip"
21
+ };
22
+ function toAhaSendAddress(address) {
23
+ const trimmed = address.trim();
24
+ const open = trimmed.lastIndexOf("<");
25
+ if (open === -1 || !trimmed.endsWith(">")) {
26
+ return { email: trimmed };
27
+ }
28
+ const email = trimmed.slice(open + 1, -1).trim();
29
+ let name = trimmed.slice(0, open).trim();
30
+ if (name.length >= 2 && name.startsWith('"') && name.endsWith('"')) {
31
+ name = name.slice(1, -1);
32
+ }
33
+ return name ? { name, email } : { email };
34
+ }
35
+ function toAhaSendAddresses(addresses) {
36
+ return addresses.length > 0 ? addresses.map(toAhaSendAddress) : void 0;
37
+ }
38
+ async function toAhaSendAttachment(attachment) {
39
+ const isUrl = /^https?:\/\//.test(attachment.path ?? "");
40
+ const pathName = attachment.path && isUrl ? new URL(attachment.path).pathname : attachment.path;
41
+ const fileName = attachment.filename ?? (pathName ? path.basename(pathName) : void 0);
42
+ if (!fileName) {
43
+ throw new Error(
44
+ "AhaSend requires a filename for every attachment. Set `filename` on attachments that are passed as `content`."
45
+ );
46
+ }
47
+ const contentType = contentTypesByExtension[path.extname(fileName).toLowerCase()] ?? "application/octet-stream";
48
+ if (typeof attachment.content === "string") {
49
+ return {
50
+ file_name: fileName,
51
+ content_type: contentType,
52
+ data: attachment.content
53
+ };
54
+ }
55
+ let content = attachment.content;
56
+ if (!content && attachment.path) {
57
+ content = isUrl ? await fetchAttachment(attachment.path) : await readFile(attachment.path);
58
+ }
59
+ if (!content) {
60
+ throw new Error(
61
+ `The attachment "${fileName}" has neither \`content\` nor \`path\`.`
62
+ );
63
+ }
64
+ return {
65
+ file_name: fileName,
66
+ content_type: contentType,
67
+ data: content.toString("base64"),
68
+ base64: true
69
+ };
70
+ }
71
+ async function fetchAttachment(url) {
72
+ const response = await fetch(url);
73
+ if (!response.ok) {
74
+ throw new Error(
75
+ `Failed to fetch the attachment ${url}: ${response.status} ` + response.statusText
76
+ );
77
+ }
78
+ return Buffer.from(await response.arrayBuffer());
79
+ }
80
+ class AhaSendMailHandler extends AbstractMailHandler {
81
+ client;
82
+ /**
83
+ * Accepts every `AhaSendClient` option. `apiKey` and `accountId` are
84
+ * required; the others, such as `retry` and `timeoutMs`, are optional.
85
+ */
86
+ constructor(options) {
87
+ super();
88
+ this.client = new AhaSendClient(options);
89
+ }
90
+ async send(content, sendOptions, handlerOptions) {
91
+ const { idempotencyKey, ...messageOptions } = handlerOptions ?? {};
92
+ const attachments = await Promise.all(
93
+ sendOptions.attachments.map(toAhaSendAttachment)
94
+ );
95
+ let response;
96
+ try {
97
+ response = await this.client.messages.sendConversation(
98
+ {
99
+ ...messageOptions,
100
+ // Standard options
101
+ attachments: attachments.length > 0 ? attachments : void 0,
102
+ bcc: toAhaSendAddresses(sendOptions.bcc),
103
+ cc: toAhaSendAddresses(sendOptions.cc),
104
+ from: toAhaSendAddress(sendOptions.from),
105
+ headers: sendOptions.headers,
106
+ reply_to: sendOptions.replyTo ? toAhaSendAddress(sendOptions.replyTo) : void 0,
107
+ subject: sendOptions.subject,
108
+ to: sendOptions.to.map(toAhaSendAddress),
109
+ // Content
110
+ html_content: content.html,
111
+ text_content: content.text
112
+ },
113
+ { idempotencyKey }
114
+ );
115
+ } catch (error) {
116
+ const message = error instanceof Error ? error.message : String(error);
117
+ throw new Error(`AhaSend failed to send the email: ${message}`, {
118
+ cause: error
119
+ });
120
+ }
121
+ const accepted = response.data.filter((result) => result.status !== "error");
122
+ if (accepted.length === 0) {
123
+ const reasons = response.data.map((result) => `${result.recipient.email}: ${result.error}`).join("; ");
124
+ throw new Error(
125
+ `AhaSend did not accept any recipient of the email: ${reasons}`,
126
+ { cause: response }
127
+ );
128
+ }
129
+ return {
130
+ messageID: accepted[0].id ?? void 0,
131
+ handlerInformation: response
132
+ };
133
+ }
134
+ internal() {
135
+ return {
136
+ client: this.client
137
+ };
138
+ }
139
+ }
140
+ export {
141
+ AhaSendMailHandler
142
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@cedarjs/mailer-handler-ahasend",
3
+ "version": "7.0.0-canary.3261",
4
+ "repository": {
5
+ "type": "git",
6
+ "url": "git+https://github.com/cedarjs/cedar.git",
7
+ "directory": "packages/mailer/handlers/ahasend"
8
+ },
9
+ "license": "MIT",
10
+ "type": "module",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "files": [
14
+ "dist"
15
+ ],
16
+ "scripts": {
17
+ "build": "node ./build.mts",
18
+ "build:pack": "yarn pack -o cedarjs-mailer-handler-ahasend.tgz",
19
+ "build:types": "tsc --build --verbose ./tsconfig.build.json",
20
+ "build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"",
21
+ "prepublishOnly": "NODE_ENV=production yarn build",
22
+ "test": "vitest run",
23
+ "test:watch": "vitest watch"
24
+ },
25
+ "dependencies": {
26
+ "@ahasend/sdk": "0.2.1",
27
+ "@cedarjs/mailer-core": "7.0.0-canary.3261"
28
+ },
29
+ "devDependencies": {
30
+ "@cedarjs/framework-tools": "7.0.0-canary.3261",
31
+ "typescript": "5.9.3",
32
+ "vitest": "4.1.11"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ }
37
+ }