coss 0.4.0 → 0.5.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.
@@ -0,0 +1,35 @@
1
+ import { Aa as CossError, Oa as FetchLike, t as Coss } from "./client-avnHVMry.js";
2
+ //#region src/core/integration.d.ts
3
+ /** How an integration reaches coss.com: a client of your own, or the options to create one. */
4
+ interface IntegrationClientOptions {
5
+ /**
6
+ * A client to send with. When set, `apiKey`, `baseUrl` and `fetch` are ignored. Otherwise the
7
+ * integration creates its own client on first send, so a missing key fails the first send rather
8
+ * than your app's startup (or its build).
9
+ */
10
+ client?: Coss;
11
+ /** Defaults to the `COSS_API_KEY` env variable. */
12
+ apiKey?: string;
13
+ /** Defaults to the `COSS_BASE_URL` env variable, then `https://api.coss.com`. */
14
+ baseUrl?: string;
15
+ fetch?: FetchLike;
16
+ }
17
+ /**
18
+ * Thrown by the integrations when coss.com refuses or can't take an email. Integrations throw (unlike
19
+ * `coss.email.send`, which returns `{ data, error }`) because the libraries they plug into report
20
+ * failures that way. The API's error is on `error`.
21
+ */
22
+ declare class CossSendError extends Error {
23
+ override readonly name = "CossSendError";
24
+ /** The `CossError` the API (or the client) returned. */
25
+ readonly error: CossError;
26
+ /** `error.code`, e.g. `domain_not_verified`. */
27
+ readonly code: string;
28
+ /** HTTP status, `null` for connection errors and timeouts. */
29
+ readonly status: number | null;
30
+ /** The API request id (`req_…`) to quote to support. */
31
+ readonly requestId: string | null;
32
+ constructor(error: CossError);
33
+ }
34
+ //#endregion
35
+ export { IntegrationClientOptions as n, CossSendError as t };
@@ -0,0 +1,152 @@
1
+ import { n as INTEGRATION, t as Coss } from "./client-CCVBtkpV.js";
2
+ //#region src/core/integration.ts
3
+ /** Returns a getter for the client, created (and validated) the first time it is needed. */
4
+ function lazyClient(integration, options, apiKey) {
5
+ let client = options.client;
6
+ return () => {
7
+ client ??= new Coss({
8
+ apiKey: apiKey?.() ?? options.apiKey,
9
+ baseUrl: options.baseUrl,
10
+ fetch: options.fetch,
11
+ [INTEGRATION]: integration
12
+ });
13
+ return client;
14
+ };
15
+ }
16
+ /**
17
+ * Thrown by the integrations when coss.com refuses or can't take an email. Integrations throw (unlike
18
+ * `coss.email.send`, which returns `{ data, error }`) because the libraries they plug into report
19
+ * failures that way. The API's error is on `error`.
20
+ */
21
+ var CossSendError = class extends Error {
22
+ name = "CossSendError";
23
+ /** The `CossError` the API (or the client) returned. */
24
+ error;
25
+ /** `error.code`, e.g. `domain_not_verified`. */
26
+ code;
27
+ /** HTTP status, `null` for connection errors and timeouts. */
28
+ status;
29
+ /** The API request id (`req_…`) to quote to support. */
30
+ requestId;
31
+ constructor(error) {
32
+ const issues = error.issues?.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
33
+ super(`coss.com could not send the email: ${error.message} (${error.code})${issues ? ` ${issues}` : ""}`);
34
+ this.error = error;
35
+ this.code = error.code;
36
+ this.status = error.status;
37
+ this.requestId = error.request_id;
38
+ }
39
+ };
40
+ //#endregion
41
+ //#region src/core/mime.ts
42
+ /**
43
+ * Helpers the integrations (`coss/nodemailer`, `coss/better-auth`, `coss/authjs`) share to turn a
44
+ * framework's message into a `SendEmailParams`. No `node:*`: like the rest of the SDK this runs on
45
+ * Node, Bun, Deno and Workers.
46
+ */
47
+ /**
48
+ * Headers the API refuses because coss.com or the provider sets them (`RESERVED_HEADERS` in
49
+ * `packages/email/src/schemas.ts`, mirrored by hand; `apps/api/test/integrations-contract.test.ts`
50
+ * sends every one). Mailers set several of these on every message, so they are dropped, not sent.
51
+ */
52
+ const RESERVED_HEADERS = /* @__PURE__ */ new Set([
53
+ "from",
54
+ "to",
55
+ "cc",
56
+ "bcc",
57
+ "reply-to",
58
+ "subject",
59
+ "sender",
60
+ "return-path",
61
+ "date",
62
+ "message-id",
63
+ "mime-version",
64
+ "content-type",
65
+ "content-transfer-encoding",
66
+ "content-disposition",
67
+ "dkim-signature",
68
+ "received"
69
+ ]);
70
+ /** Whether the API would refuse a custom header with this name. */
71
+ function isReservedHeader(name) {
72
+ const lower = name.toLowerCase();
73
+ return RESERVED_HEADERS.has(lower) || lower.startsWith("x-ses-");
74
+ }
75
+ const NAME_SPECIALS = /["<>,;:@()[\]\\]/;
76
+ /** `addr`, or `Name <addr>` with the name quoted when it needs to be. */
77
+ function formatAddress(address, name) {
78
+ const trimmedName = name?.trim();
79
+ if (!trimmedName) return address;
80
+ return `${NAME_SPECIALS.test(trimmedName) ? `"${trimmedName.replace(/["\\]/g, "\\$&")}"` : trimmedName} <${address}>`;
81
+ }
82
+ const PRINTABLE_ASCII = /^[ -~]*$/;
83
+ const ENCODED_WORD_BYTES = 45;
84
+ /**
85
+ * A header value as the API takes it: one line of printable ASCII. Folded lines are unfolded, and
86
+ * anything non-ASCII becomes RFC 2047 encoded-words (`=?UTF-8?B?…?=`), which mail clients decode.
87
+ */
88
+ function encodeHeaderValue(value) {
89
+ const unfolded = value.replace(/\r?\n[ \t]*/g, " ").replace(/\p{Cc}/gu, " ").trim();
90
+ if (PRINTABLE_ASCII.test(unfolded)) return unfolded;
91
+ const encoder = new TextEncoder();
92
+ const words = [];
93
+ let chunk = [];
94
+ for (const character of unfolded) {
95
+ const bytes = encoder.encode(character);
96
+ if (chunk.length + bytes.length > ENCODED_WORD_BYTES) {
97
+ words.push(`=?UTF-8?B?${toBase64(Uint8Array.from(chunk))}?=`);
98
+ chunk = [];
99
+ }
100
+ chunk.push(...bytes);
101
+ }
102
+ if (chunk.length > 0) words.push(`=?UTF-8?B?${toBase64(Uint8Array.from(chunk))}?=`);
103
+ return words.join(" ");
104
+ }
105
+ /**
106
+ * Custom headers for the API: reserved ones dropped, values encoded, empty ones left out (the API
107
+ * needs at least one character). Names are kept as given; the API treats them case-insensitively.
108
+ * `undefined` when none is left.
109
+ */
110
+ function customHeaders(entries) {
111
+ const headers = {};
112
+ let any = false;
113
+ for (const [name, value] of entries) {
114
+ const trimmedName = name.trim();
115
+ if (!trimmedName || isReservedHeader(trimmedName)) continue;
116
+ const encoded = encodeHeaderValue(value);
117
+ if (!encoded) continue;
118
+ headers[trimmedName] = encoded;
119
+ any = true;
120
+ }
121
+ return any ? headers : void 0;
122
+ }
123
+ const BASE64_CHUNK = 32768;
124
+ /** Standard, padded base64 of `bytes`. */
125
+ function toBase64(bytes) {
126
+ let binary = "";
127
+ for (let i = 0; i < bytes.length; i += BASE64_CHUNK) binary += String.fromCharCode(...bytes.subarray(i, i + BASE64_CHUNK));
128
+ return btoa(binary);
129
+ }
130
+ /** Base64 of a string's UTF-8 bytes. */
131
+ function utf8ToBase64(value) {
132
+ return toBase64(new TextEncoder().encode(value));
133
+ }
134
+ /** `type/subtype` without parameters (`text/plain; charset=utf-8` → `text/plain`). */
135
+ function mediaType(contentType) {
136
+ return contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
137
+ }
138
+ /** A Content-ID without its angle brackets (`<logo@acme>` → `logo@acme`). */
139
+ function bareContentId(cid) {
140
+ return cid?.trim().replace(/^<|>$/g, "") || void 0;
141
+ }
142
+ /** Hex SHA-256 of `value` (Web Crypto). */
143
+ async function sha256Hex(value) {
144
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
145
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
146
+ }
147
+ /** Escapes text for HTML element content and quoted attribute values. */
148
+ function escapeHtml(value) {
149
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
150
+ }
151
+ //#endregion
152
+ export { mediaType as a, CossSendError as c, formatAddress as i, lazyClient as l, customHeaders as n, sha256Hex as o, escapeHtml as r, utf8ToBase64 as s, bareContentId as t };
@@ -0,0 +1,75 @@
1
+ import { Ci as Email, Ii as EmailTag, Ki as TrackingOptions } from "./client-avnHVMry.js";
2
+ import { n as IntegrationClientOptions, t as CossSendError } from "./integration-p3vtLNgq.js";
3
+ //#region src/nodemailer.d.ts
4
+ /** coss.com settings for one message, passed as `coss` next to Nodemailer's own fields (see `withCoss`). */
5
+ export interface CossMessageOptions {
6
+ /** What kind of email this is, e.g. `"marketing"`. Recipients suppressed for it are skipped. */
7
+ category?: string;
8
+ /** Merged with the transport's `tags`; a message tag replaces a transport tag of the same name. */
9
+ tags?: EmailTag[];
10
+ /** Send later: a `Date`, or an ISO 8601 date-time with an offset, at most 30 days ahead. */
11
+ scheduledAt?: Date | string;
12
+ tracking?: TrackingOptions;
13
+ /**
14
+ * Sent as `Idempotency-Key`: a retry with the same key never sends twice. Defaults to a random
15
+ * key per `sendMail` call (still reused by the client's own retries).
16
+ */
17
+ idempotencyKey?: string;
18
+ }
19
+ /** Options of `cossTransport`. The coss.com fields apply to every message unless the message sets them. */
20
+ export interface CossTransportOptions extends IntegrationClientOptions {
21
+ category?: string;
22
+ tags?: EmailTag[];
23
+ tracking?: TrackingOptions;
24
+ }
25
+ /** What `sendMail` resolves with. */
26
+ export interface CossSentMessageInfo {
27
+ /** The coss.com email id (`email_…`): look it up with `coss.email.get(id)` or in the dashboard. */
28
+ messageId: string;
29
+ envelope: {
30
+ from: string | false;
31
+ to: string[];
32
+ };
33
+ /** Every envelope recipient: coss.com took the email for all of them (or none, as an error). */
34
+ accepted: string[];
35
+ rejected: string[];
36
+ pending: string[];
37
+ /** e.g. `202 queued`. */
38
+ response: string;
39
+ /** The email as the API returned it. */
40
+ email: Email;
41
+ }
42
+ /** The parts of Nodemailer's `MailMessage` the transport reads. */
43
+ export interface NodemailerMailMessage {
44
+ data: object;
45
+ message: {
46
+ getEnvelope(): {
47
+ from: string | false;
48
+ to: string[];
49
+ };
50
+ };
51
+ normalize(callback: (err: Error | null, data?: object) => void): void;
52
+ }
53
+ /** A Nodemailer transport (`nodemailer.createTransport(cossTransport())`). */
54
+ export interface CossTransport {
55
+ name: string;
56
+ version: string;
57
+ send(mail: NodemailerMailMessage, callback: (err: Error | null, info?: CossSentMessageInfo) => void): void;
58
+ /** Checks the configuration (an API key is set) without sending anything. */
59
+ verify(callback?: (err: Error | null, success?: true) => void): Promise<true> | undefined;
60
+ }
61
+ /**
62
+ * Adds coss.com settings to a message. Use this rather than an inline `coss` field: Nodemailer's
63
+ * `SendMailOptions` type has no `coss` property, so an object literal with one doesn't typecheck.
64
+ *
65
+ * ```ts
66
+ * await transporter.sendMail(withCoss({ from, to, subject, html }, { category: "receipts" }));
67
+ * ```
68
+ */
69
+ export declare function withCoss<T extends object>(mail: T, options: CossMessageOptions): T & {
70
+ coss: CossMessageOptions;
71
+ };
72
+ /** A Nodemailer transport that sends every message with `coss.email.send`. */
73
+ export declare function cossTransport(options?: CossTransportOptions): CossTransport;
74
+ //#endregion
75
+ export { CossSendError };
@@ -0,0 +1,193 @@
1
+ import { r as VERSION } from "./client-CCVBtkpV.js";
2
+ import { a as mediaType, c as CossSendError, i as formatAddress, l as lazyClient, n as customHeaders, s as utf8ToBase64, t as bareContentId } from "./mime-C8bhhjvD.js";
3
+ //#region src/nodemailer.ts
4
+ /**
5
+ * Adds coss.com settings to a message. Use this rather than an inline `coss` field: Nodemailer's
6
+ * `SendMailOptions` type has no `coss` property, so an object literal with one doesn't typecheck.
7
+ *
8
+ * ```ts
9
+ * await transporter.sendMail(withCoss({ from, to, subject, html }, { category: "receipts" }));
10
+ * ```
11
+ */
12
+ function withCoss(mail, options) {
13
+ return {
14
+ ...mail,
15
+ coss: options
16
+ };
17
+ }
18
+ /** A Nodemailer transport that sends every message with `coss.email.send`. */
19
+ function cossTransport(options = {}) {
20
+ const client = lazyClient("nodemailer", options);
21
+ return {
22
+ name: "coss.com",
23
+ version: VERSION,
24
+ send(mail, callback) {
25
+ send(mail).then((info) => callback(null, info), (error) => callback(error instanceof Error ? error : new Error(String(error))));
26
+ },
27
+ verify(callback) {
28
+ const check = async () => {
29
+ client();
30
+ return true;
31
+ };
32
+ if (!callback) return check();
33
+ check().then((success) => callback(null, success), (error) => callback(error instanceof Error ? error : new Error(String(error))));
34
+ }
35
+ };
36
+ async function send(mail) {
37
+ const raw = mail.data;
38
+ unsupported(raw.raw, "`raw` messages", "build the message from its fields instead");
39
+ unsupported(raw.envelope, "a custom `envelope`", "coss.com sends to the message's recipients");
40
+ unsupported(raw.sender, "`sender`", "coss.com sets the Sender header itself");
41
+ unsupported(raw.amp, "AMP (`amp`) bodies", "send `html` and `text`");
42
+ unsupported(raw.watchHtml, "`watchHtml`", "send `html` and `text`");
43
+ if (Array.isArray(raw.alternatives) && raw.alternatives.length > 0) unsupported(true, "`alternatives`", "send `html`, `text` and attachments");
44
+ if (Array.isArray(raw.attachments) && raw.attachments.some(hasRawMime)) unsupported(true, "attachments with `raw` MIME", "give `content`, `path` or `href`");
45
+ const ownHeaders = messageHeaders(raw.headers);
46
+ const envelope = mail.message.getEnvelope();
47
+ const data = await new Promise((resolve, reject) => {
48
+ mail.normalize((err, normalized) => err ? reject(err) : resolve(normalized ?? {}));
49
+ });
50
+ const coss = data.coss ?? {};
51
+ const params = {
52
+ from: required(data.from ? addresses([data.from])[0] : void 0, "from"),
53
+ to: addresses(data.to),
54
+ subject: data.subject ?? ""
55
+ };
56
+ const cc = addresses(data.cc);
57
+ if (cc.length > 0) params.cc = cc;
58
+ const bcc = addresses(data.bcc);
59
+ if (bcc.length > 0) params.bcc = bcc;
60
+ const replyTo = addresses(data.replyTo);
61
+ if (replyTo.length > 0) params.reply_to = replyTo;
62
+ if (typeof data.html === "string" && data.html) params.html = data.html;
63
+ if (typeof data.text === "string" && data.text) params.text = data.text;
64
+ const attachments = (data.attachments ?? []).map(attachment);
65
+ if (data.icalEvent) attachments.push(icalAttachment(data.icalEvent));
66
+ if (attachments.length > 0) params.attachments = attachments;
67
+ const derived = Object.entries(data.normalizedHeaders ?? {}).filter(([name]) => !ownHeaders.names.has(name));
68
+ const headers = customHeaders([
69
+ ...ownHeaders.entries,
70
+ ...derived,
71
+ ...priorityHeaders(data.priority),
72
+ ...typeof data.xMailer === "string" ? [["X-Mailer", data.xMailer]] : []
73
+ ]);
74
+ if (headers) params.headers = headers;
75
+ const category = coss.category ?? options.category;
76
+ if (category) params.category = category;
77
+ const tags = mergeTags(options.tags, coss.tags);
78
+ if (tags) params.tags = tags;
79
+ const tracking = coss.tracking ?? options.tracking;
80
+ if (tracking) params.tracking = tracking;
81
+ if (coss.scheduledAt) params.scheduled_at = coss.scheduledAt instanceof Date ? coss.scheduledAt.toISOString() : coss.scheduledAt;
82
+ const result = await client().email.send(params, { idempotencyKey: coss.idempotencyKey });
83
+ if (result.error) throw new CossSendError(result.error);
84
+ return {
85
+ messageId: result.data.id,
86
+ envelope: {
87
+ from: envelope.from,
88
+ to: envelope.to
89
+ },
90
+ accepted: [...envelope.to],
91
+ rejected: [],
92
+ pending: [],
93
+ response: `202 ${result.data.status}`,
94
+ email: result.data
95
+ };
96
+ }
97
+ }
98
+ function unsupported(value, what, instead) {
99
+ if (value === void 0 || value === null || value === false || value === "") return;
100
+ throw new Error(`coss/nodemailer: ${what} can't be sent through coss.com; ${instead}.`);
101
+ }
102
+ function hasRawMime(attachment) {
103
+ if (!attachment || typeof attachment !== "object" || !("raw" in attachment)) return false;
104
+ return attachment.raw !== void 0 && attachment.raw !== null && attachment.raw !== false;
105
+ }
106
+ function required(value, field) {
107
+ if (value === void 0) throw new Error(`coss/nodemailer: the message has no \`${field}\`.`);
108
+ return value;
109
+ }
110
+ /**
111
+ * A message's `headers` (an object, or Nodemailer's `[{ key, value }]` array form) as name/value
112
+ * pairs, plus the keys normalize() files them under. coss.com sends each header once, so one
113
+ * given several values is refused rather than sent with only the first.
114
+ */
115
+ function messageHeaders(headers) {
116
+ const entries = [];
117
+ const names = /* @__PURE__ */ new Set();
118
+ if (!headers || typeof headers !== "object") return {
119
+ entries,
120
+ names
121
+ };
122
+ const pairs = Array.isArray(headers) ? headers.map((entry) => [typeof entry?.key === "string" ? entry.key : "", entry?.value]) : Object.entries(headers);
123
+ for (const key of Object.keys(headers)) names.add(key);
124
+ const seen = /* @__PURE__ */ new Set();
125
+ for (const [name, given] of pairs) {
126
+ const values = (Array.isArray(given) ? given : [given]).map((value) => value && typeof value === "object" && "value" in value ? value.value : value).filter((value) => value !== void 0 && value !== null && value !== false && value !== "");
127
+ if (!name || values.length === 0) continue;
128
+ const lower = name.trim().toLowerCase();
129
+ if (values.length > 1 || seen.has(lower)) throw new Error(`coss/nodemailer: the \`${name}\` header is given more than once; coss.com sends each header once.`);
130
+ seen.add(lower);
131
+ entries.push([name, String(values[0])]);
132
+ }
133
+ return {
134
+ entries,
135
+ names
136
+ };
137
+ }
138
+ /** Parsed addresses (groups flattened) as `Name <addr>` strings. */
139
+ function addresses(list) {
140
+ const out = [];
141
+ for (const entry of list ?? []) if (entry.group) out.push(...addresses(entry.group));
142
+ else if (entry.address) out.push(formatAddress(entry.address, entry.name));
143
+ return out;
144
+ }
145
+ function attachment(file, index) {
146
+ const params = {
147
+ filename: typeof file.filename === "string" ? file.filename : `attachment-${index + 1}`,
148
+ content: base64Content(file)
149
+ };
150
+ const contentType = mediaType(file.contentType);
151
+ if (contentType) params.content_type = contentType;
152
+ const contentId = bareContentId(file.cid);
153
+ if (contentId) params.content_id = contentId;
154
+ return params;
155
+ }
156
+ function icalAttachment(event) {
157
+ return {
158
+ filename: typeof event.filename === "string" ? event.filename : "invite.ics",
159
+ content: base64Content(event),
160
+ content_type: "text/calendar"
161
+ };
162
+ }
163
+ /** After normalize(), content is base64 (it was binary) or a UTF-8 string. */
164
+ function base64Content(file) {
165
+ const content = file.content;
166
+ if (content === void 0 || content === null) return "";
167
+ if (typeof content !== "string") throw new Error("coss/nodemailer: an attachment's content could not be read.");
168
+ return file.encoding?.toLowerCase() === "base64" ? content.replace(/\s+/g, "") : utf8ToBase64(content);
169
+ }
170
+ /** The headers Nodemailer adds for `priority` (it adds them to the MIME tree, not `normalizedHeaders`). */
171
+ function priorityHeaders(priority) {
172
+ switch (priority?.toLowerCase()) {
173
+ case "high": return [
174
+ ["X-Priority", "1 (Highest)"],
175
+ ["X-MSMail-Priority", "High"],
176
+ ["Importance", "High"]
177
+ ];
178
+ case "low": return [
179
+ ["X-Priority", "5 (Lowest)"],
180
+ ["X-MSMail-Priority", "Low"],
181
+ ["Importance", "Low"]
182
+ ];
183
+ default: return [];
184
+ }
185
+ }
186
+ function mergeTags(defaults, message) {
187
+ if (!defaults?.length && !message?.length) return void 0;
188
+ const byName = /* @__PURE__ */ new Map();
189
+ for (const tag of [...defaults ?? [], ...message ?? []]) byName.set(tag.name, tag);
190
+ return [...byName.values()];
191
+ }
192
+ //#endregion
193
+ export { CossSendError, cossTransport, withCoss };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coss",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "The official TypeScript SDK for coss.com: email, SMS, video, scheduling and more behind one API key.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://coss.com/docs",
@@ -23,7 +23,10 @@
23
23
  "types": "./dist/index.d.ts",
24
24
  "exports": {
25
25
  ".": "./dist/index.js",
26
+ "./authjs": "./dist/authjs.js",
27
+ "./better-auth": "./dist/better-auth.js",
26
28
  "./device": "./dist/device.js",
29
+ "./nodemailer": "./dist/nodemailer.js",
27
30
  "./package.json": "./package.json"
28
31
  },
29
32
  "scripts": {
@@ -32,18 +35,33 @@
32
35
  "test": "bun test"
33
36
  },
34
37
  "peerDependencies": {
35
- "@react-email/render": "^1.0.0 || ^2.0.0"
38
+ "@auth/core": ">=0.37.0",
39
+ "@react-email/render": "^1.0.0 || ^2.0.0",
40
+ "better-auth": ">=1.0.0",
41
+ "nodemailer": ">=6.9.0"
36
42
  },
37
43
  "peerDependenciesMeta": {
44
+ "@auth/core": {
45
+ "optional": true
46
+ },
38
47
  "@react-email/render": {
39
48
  "optional": true
49
+ },
50
+ "better-auth": {
51
+ "optional": true
52
+ },
53
+ "nodemailer": {
54
+ "optional": true
40
55
  }
41
56
  },
42
57
  "devDependencies": {
43
58
  "@arethetypeswrong/core": "0.18.5",
59
+ "@auth/core": "0.41.3",
44
60
  "@react-email/render": "2.1.0",
45
61
  "@types/bun": "1.4.2",
46
62
  "@types/react": "19.3.0",
63
+ "better-auth": "1.7.7",
64
+ "nodemailer": "10.0.13",
47
65
  "publint": "0.3.25",
48
66
  "react": "19.3.0",
49
67
  "react-dom": "19.3.0",