@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 +122 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +142 -0
- package/package.json +37 -0
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`.
|
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|