@pya-platform/email 0.1.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/CHANGELOG.md +9 -0
- package/package.json +25 -0
- package/src/index.ts +87 -0
- package/tsconfig.json +10 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @pya/email
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- a9ca6bf: Initial release of the Pya platform packages. Extracted from `pyaeats-app`, consumed by `pyaeats-app` (food delivery) and `pyaserv` (services classifieds).
|
|
8
|
+
|
|
9
|
+
Each package exposes a Hono router factory (auth/cms/reviews/comments) or a typed helper (email/audit/cf) parameterised over Cloudflare D1 + KV bindings. UI primitives ship as Lit web components on top of `@pya/tokens` (CSS custom properties). See `ROADMAP.md` and `docs/phase-6-rollout.md` for the consumer cutover plan.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pya-platform/email",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"registry": "https://registry.npmjs.org",
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/undeadliner/pya-platform.git"
|
|
12
|
+
},
|
|
13
|
+
"type": "module",
|
|
14
|
+
"description": "Resend wrapper — single sendEmail entry, audit-log on failure, never throws.",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./src/index.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"type-check": "tsc --noEmit",
|
|
20
|
+
"test": "echo '@pya/email has no tests yet'"
|
|
21
|
+
},
|
|
22
|
+
"peerDependencies": {
|
|
23
|
+
"@cloudflare/workers-types": "^4.20240909.0"
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// @pya-platform/email — Resend wrapper.
|
|
2
|
+
//
|
|
3
|
+
// Single entry: `sendEmail()`. Never throws — notification failure
|
|
4
|
+
// must not block whatever caller transition already succeeded.
|
|
5
|
+
// Logs to the `audit` stream on failure so it's still observable.
|
|
6
|
+
|
|
7
|
+
export interface PyaEmailBindings {
|
|
8
|
+
readonly RESEND_API_KEY?: string
|
|
9
|
+
readonly EMAIL_DOMAIN?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface SendEmailParams {
|
|
13
|
+
readonly env: PyaEmailBindings
|
|
14
|
+
readonly to: string
|
|
15
|
+
readonly subject: string
|
|
16
|
+
readonly text: string
|
|
17
|
+
readonly html?: string
|
|
18
|
+
/** Brand name shown in the `From:` header. Defaults to "Pya". */
|
|
19
|
+
readonly brandName?: string
|
|
20
|
+
/** Mailbox local-part. Defaults to "noreply". */
|
|
21
|
+
readonly fromLocal?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const senderAddress = (env: PyaEmailBindings, brand: string, local: string): string => {
|
|
25
|
+
if (env.EMAIL_DOMAIN !== undefined && env.EMAIL_DOMAIN !== '') {
|
|
26
|
+
return `${brand} <${local}@${env.EMAIL_DOMAIN}>`
|
|
27
|
+
}
|
|
28
|
+
// Resend's free sandbox sender — usable until the domain is verified.
|
|
29
|
+
return `${brand} <onboarding@resend.dev>`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const sendEmail = async (params: SendEmailParams): Promise<{ readonly ok: boolean }> => {
|
|
33
|
+
const { env, to, subject, text, html, brandName = 'Pya', fromLocal = 'noreply' } = params
|
|
34
|
+
const apiKey = env.RESEND_API_KEY ?? ''
|
|
35
|
+
if (apiKey === '') {
|
|
36
|
+
console.error(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
stream: 'audit',
|
|
39
|
+
event: 'email.send_skipped',
|
|
40
|
+
reason: 'RESEND_API_KEY unset',
|
|
41
|
+
to,
|
|
42
|
+
subject,
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
return { ok: false }
|
|
46
|
+
}
|
|
47
|
+
const res = await fetch('https://api.resend.com/emails', {
|
|
48
|
+
method: 'POST',
|
|
49
|
+
headers: {
|
|
50
|
+
Authorization: `Bearer ${apiKey}`,
|
|
51
|
+
'Content-Type': 'application/json',
|
|
52
|
+
},
|
|
53
|
+
body: JSON.stringify({
|
|
54
|
+
from: senderAddress(env, brandName, fromLocal),
|
|
55
|
+
to,
|
|
56
|
+
subject,
|
|
57
|
+
text,
|
|
58
|
+
...(html !== undefined ? { html } : {}),
|
|
59
|
+
}),
|
|
60
|
+
})
|
|
61
|
+
if (!res.ok) {
|
|
62
|
+
const body = await res.text().catch(() => '')
|
|
63
|
+
console.error(
|
|
64
|
+
JSON.stringify({
|
|
65
|
+
stream: 'audit',
|
|
66
|
+
event: 'email.send_failed',
|
|
67
|
+
provider: 'resend',
|
|
68
|
+
status: res.status,
|
|
69
|
+
to,
|
|
70
|
+
subject,
|
|
71
|
+
body: body.slice(0, 500),
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
return { ok: false }
|
|
75
|
+
}
|
|
76
|
+
return { ok: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Minimal HTML escape for embedding user-supplied text in templates.
|
|
80
|
+
* Use ONLY where the consumer is the email client, not the browser DOM. */
|
|
81
|
+
export const escapeHtml = (s: string): string =>
|
|
82
|
+
s
|
|
83
|
+
.replaceAll('&', '&')
|
|
84
|
+
.replaceAll('<', '<')
|
|
85
|
+
.replaceAll('>', '>')
|
|
86
|
+
.replaceAll('"', '"')
|
|
87
|
+
.replaceAll("'", ''')
|