@sentroy-co/client-sdk 2.1.0 → 2.2.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.
Files changed (39) hide show
  1. package/dist/react/MediaManager.d.ts +69 -0
  2. package/dist/react/MediaManager.d.ts.map +1 -0
  3. package/dist/react/MediaManager.js +216 -0
  4. package/dist/react/MediaManager.js.map +1 -0
  5. package/dist/react/index.d.ts +4 -0
  6. package/dist/react/index.d.ts.map +1 -0
  7. package/dist/react/index.js +13 -0
  8. package/dist/react/index.js.map +1 -0
  9. package/dist/react/lib/Lightbox.d.ts +18 -0
  10. package/dist/react/lib/Lightbox.d.ts.map +1 -0
  11. package/dist/react/lib/Lightbox.js +40 -0
  12. package/dist/react/lib/Lightbox.js.map +1 -0
  13. package/dist/react/lib/use-media-list.d.ts +21 -0
  14. package/dist/react/lib/use-media-list.d.ts.map +1 -0
  15. package/dist/react/lib/use-media-list.js +50 -0
  16. package/dist/react/lib/use-media-list.js.map +1 -0
  17. package/dist/react/lib/utils.d.ts +17 -0
  18. package/dist/react/lib/utils.d.ts.map +1 -0
  19. package/dist/react/lib/utils.js +62 -0
  20. package/dist/react/lib/utils.js.map +1 -0
  21. package/dist/types.d.ts +5 -0
  22. package/dist/types.d.ts.map +1 -1
  23. package/package.json +40 -4
  24. package/src/http.ts +151 -0
  25. package/src/index.ts +106 -0
  26. package/src/react/MediaManager.tsx +628 -0
  27. package/src/react/index.ts +13 -0
  28. package/src/react/lib/Lightbox.tsx +162 -0
  29. package/src/react/lib/use-media-list.ts +54 -0
  30. package/src/react/lib/utils.ts +73 -0
  31. package/src/resources/buckets.ts +50 -0
  32. package/src/resources/domains.ts +16 -0
  33. package/src/resources/inbox.ts +99 -0
  34. package/src/resources/mailboxes.ts +11 -0
  35. package/src/resources/media.ts +115 -0
  36. package/src/resources/send.ts +11 -0
  37. package/src/resources/storage.ts +28 -0
  38. package/src/resources/templates.ts +16 -0
  39. package/src/types.ts +316 -0
@@ -0,0 +1,162 @@
1
+ import { useEffect } from "react"
2
+ import type { Media } from "../../types"
3
+ import { detectKind, formatBytes } from "./utils"
4
+
5
+ /**
6
+ * Sadeleştirilmiş lightbox — tek media item için fullscreen preview.
7
+ * Image/video native render, audio HTML5 player, diğerleri "download"
8
+ * fallback. ESC kapat, ←/→ next/prev (caller index callback'i sağlar).
9
+ *
10
+ * Headless yapı: tüm renderable HTML/inline class'lar default; consumer
11
+ * className override'ı ile değiştirebilir.
12
+ */
13
+ export interface LightboxProps {
14
+ media: Media
15
+ onClose: () => void
16
+ onPrev?: () => void
17
+ onNext?: () => void
18
+ className?: string
19
+ }
20
+
21
+ export function Lightbox({
22
+ media,
23
+ onClose,
24
+ onPrev,
25
+ onNext,
26
+ className,
27
+ }: LightboxProps) {
28
+ useEffect(() => {
29
+ const onKey = (e: KeyboardEvent) => {
30
+ if (e.key === "Escape") onClose()
31
+ if (e.key === "ArrowLeft" && onPrev) onPrev()
32
+ if (e.key === "ArrowRight" && onNext) onNext()
33
+ }
34
+ document.addEventListener("keydown", onKey)
35
+ document.body.style.overflow = "hidden"
36
+ return () => {
37
+ document.removeEventListener("keydown", onKey)
38
+ document.body.style.overflow = ""
39
+ }
40
+ }, [onClose, onPrev, onNext])
41
+
42
+ const kind = detectKind(media)
43
+ const url = media.url || media.downloadUrl
44
+
45
+ return (
46
+ <div
47
+ role="dialog"
48
+ aria-modal="true"
49
+ onClick={(e) => {
50
+ if (e.target === e.currentTarget) onClose()
51
+ }}
52
+ className={
53
+ className ||
54
+ "fixed inset-0 z-50 flex items-center justify-center bg-black/90 p-4 backdrop-blur-sm"
55
+ }
56
+ >
57
+ {/* Close button */}
58
+ <button
59
+ type="button"
60
+ onClick={onClose}
61
+ aria-label="Close"
62
+ className="absolute right-4 top-4 rounded-full bg-white/10 p-2 text-white transition-colors hover:bg-white/20"
63
+ >
64
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
65
+ <path d="M18 6L6 18M6 6l12 12" />
66
+ </svg>
67
+ </button>
68
+
69
+ {/* Prev */}
70
+ {onPrev && (
71
+ <button
72
+ type="button"
73
+ onClick={(e) => {
74
+ e.stopPropagation()
75
+ onPrev()
76
+ }}
77
+ aria-label="Previous"
78
+ className="absolute left-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20"
79
+ >
80
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
81
+ <path d="M15 18l-6-6 6-6" />
82
+ </svg>
83
+ </button>
84
+ )}
85
+
86
+ {/* Next */}
87
+ {onNext && (
88
+ <button
89
+ type="button"
90
+ onClick={(e) => {
91
+ e.stopPropagation()
92
+ onNext()
93
+ }}
94
+ aria-label="Next"
95
+ className="absolute right-4 top-1/2 -translate-y-1/2 rounded-full bg-white/10 p-3 text-white transition-colors hover:bg-white/20"
96
+ >
97
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
98
+ <path d="M9 6l6 6-6 6" />
99
+ </svg>
100
+ </button>
101
+ )}
102
+
103
+ {/* Content */}
104
+ <div className="flex max-h-full max-w-5xl flex-col items-center gap-3">
105
+ {kind === "image" && url && (
106
+ // eslint-disable-next-line @next/next/no-img-element
107
+ <img
108
+ src={url}
109
+ alt={media.alt ?? media.fileName}
110
+ className="max-h-[80vh] max-w-full rounded-lg object-contain shadow-2xl"
111
+ />
112
+ )}
113
+ {kind === "video" && url && (
114
+ <video
115
+ src={url}
116
+ controls
117
+ autoPlay
118
+ className="max-h-[80vh] max-w-full rounded-lg shadow-2xl"
119
+ />
120
+ )}
121
+ {kind === "audio" && url && (
122
+ <div className="flex w-full max-w-md flex-col gap-3 rounded-lg bg-white/10 p-6 text-white">
123
+ <div className="text-center text-sm font-medium">
124
+ {media.fileName}
125
+ </div>
126
+ <audio src={url} controls className="w-full" />
127
+ </div>
128
+ )}
129
+ {kind !== "image" && kind !== "video" && kind !== "audio" && (
130
+ <div className="flex flex-col items-center gap-3 rounded-lg bg-white/10 p-8 text-white">
131
+ <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
132
+ <path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
133
+ <path d="M14 2v6h6" />
134
+ </svg>
135
+ <div className="text-sm font-medium">{media.fileName}</div>
136
+ <a
137
+ href={url}
138
+ download={media.fileName}
139
+ target="_blank"
140
+ rel="noreferrer"
141
+ className="rounded-md bg-white px-4 py-2 text-xs font-semibold text-black hover:bg-white/90"
142
+ >
143
+ Download
144
+ </a>
145
+ </div>
146
+ )}
147
+
148
+ <div className="flex items-center gap-3 rounded-md bg-black/40 px-3 py-1.5 text-xs text-white/80">
149
+ <span className="font-mono truncate max-w-xs">{media.fileName}</span>
150
+ <span>·</span>
151
+ <span>{formatBytes(media.size ?? 0)}</span>
152
+ {media.mimeType && (
153
+ <>
154
+ <span>·</span>
155
+ <span className="font-mono opacity-70">{media.mimeType}</span>
156
+ </>
157
+ )}
158
+ </div>
159
+ </div>
160
+ </div>
161
+ )
162
+ }
@@ -0,0 +1,54 @@
1
+ import { useCallback, useEffect, useState } from "react"
2
+ import type { Sentroy } from "../.."
3
+ import type { Media, MediaListResult } from "../../types"
4
+
5
+ /**
6
+ * Bir bucket'taki media listesini çeken hook. Search, kind filter ve
7
+ * folder filter local-side; SDK'nın list endpoint'i şimdilik bunları
8
+ * server-side desteklemiyor (genelde 100-200 dosyalık küçük scale,
9
+ * client-side OK). Büyük scale'de paginate + server filter eklenir.
10
+ */
11
+ export function useMediaList(args: {
12
+ client: Sentroy
13
+ bucketSlug: string | null
14
+ /** Yeniden tetiklenmek için artırılan key. */
15
+ refreshKey?: number
16
+ }) {
17
+ const { client, bucketSlug, refreshKey = 0 } = args
18
+ const [data, setData] = useState<MediaListResult | null>(null)
19
+ const [loading, setLoading] = useState(false)
20
+ const [error, setError] = useState<string | null>(null)
21
+
22
+ useEffect(() => {
23
+ if (!bucketSlug) {
24
+ setData(null)
25
+ return
26
+ }
27
+ let cancelled = false
28
+ setLoading(true)
29
+ setError(null)
30
+ client.media
31
+ .list(bucketSlug, { limit: 200 })
32
+ .then((res) => {
33
+ if (cancelled) return
34
+ setData(res)
35
+ })
36
+ .catch((err: unknown) => {
37
+ if (cancelled) return
38
+ setError(err instanceof Error ? err.message : "Failed to load media")
39
+ })
40
+ .finally(() => {
41
+ if (!cancelled) setLoading(false)
42
+ })
43
+ return () => {
44
+ cancelled = true
45
+ }
46
+ }, [client, bucketSlug, refreshKey])
47
+
48
+ const refresh = useCallback(() => {
49
+ // Caller kendi refreshKey'ini bump'lar; bu sadece convenience.
50
+ setData((d) => d)
51
+ }, [])
52
+
53
+ return { data, items: data?.items ?? ([] as Media[]), loading, error, refresh }
54
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Minimal class name combiner — clsx + twMerge gibi davranır ama
3
+ * dependency yok. Falsy filtrele, dedup yapma. Tema override'larında
4
+ * Tailwind utility çakışmaları için consumer kendi cn'ini kullanırsa
5
+ * `className` prop'u son geldiği için doğal şekilde kazanır.
6
+ */
7
+ export function cn(
8
+ ...args: Array<string | undefined | null | false | 0>
9
+ ): string {
10
+ return args.filter(Boolean).join(" ")
11
+ }
12
+
13
+ export function formatBytes(bytes: number, decimals = 1): string {
14
+ if (!bytes) return "0 B"
15
+ const units = ["B", "KB", "MB", "GB", "TB"]
16
+ let i = 0
17
+ let v = bytes
18
+ while (v >= 1024 && i < units.length - 1) {
19
+ v /= 1024
20
+ i++
21
+ }
22
+ return `${Number.isInteger(v) ? v : v.toFixed(decimals)} ${units[i]}`
23
+ }
24
+
25
+ /** İlk image-like extension veya MIME → "image", "video", "audio", "pdf",
26
+ * "doc" (word/excel/ppt), "archive", "code", "other". */
27
+ export function detectKind(file: {
28
+ mimeType?: string | null
29
+ fileName?: string
30
+ }): MediaKind {
31
+ const mt = (file.mimeType ?? "").toLowerCase()
32
+ const ext = (file.fileName ?? "").split(".").pop()?.toLowerCase() ?? ""
33
+ if (mt.startsWith("image/")) return "image"
34
+ if (mt.startsWith("video/")) return "video"
35
+ if (mt.startsWith("audio/")) return "audio"
36
+ if (mt === "application/pdf" || ext === "pdf") return "pdf"
37
+ if (
38
+ /^(doc|docx|xls|xlsx|ppt|pptx|odt|ods|odp|txt|md|csv)$/.test(ext) ||
39
+ mt.startsWith("application/vnd.")
40
+ ) {
41
+ return "doc"
42
+ }
43
+ if (/^(zip|tar|gz|7z|rar|bz2)$/.test(ext)) return "archive"
44
+ if (
45
+ /^(js|ts|tsx|jsx|json|html|css|scss|sh|py|go|rb|php|rs|java|c|cpp)$/.test(
46
+ ext,
47
+ )
48
+ ) {
49
+ return "code"
50
+ }
51
+ return "other"
52
+ }
53
+
54
+ export type MediaKind =
55
+ | "image"
56
+ | "video"
57
+ | "audio"
58
+ | "pdf"
59
+ | "doc"
60
+ | "archive"
61
+ | "code"
62
+ | "other"
63
+
64
+ export const KIND_LABELS: Record<MediaKind, string> = {
65
+ image: "Image",
66
+ video: "Video",
67
+ audio: "Audio",
68
+ pdf: "PDF",
69
+ doc: "Document",
70
+ archive: "Archive",
71
+ code: "Code",
72
+ other: "Other",
73
+ }
@@ -0,0 +1,50 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ Bucket,
4
+ CreateBucketParams,
5
+ UpdateBucketParams,
6
+ } from "../types"
7
+
8
+ export class Buckets {
9
+ constructor(private http: HttpClient) {}
10
+
11
+ /** List all buckets in the company. */
12
+ async list(): Promise<Bucket[]> {
13
+ return this.http.get<Bucket[]>("/buckets")
14
+ }
15
+
16
+ /** Get a single bucket by its slug. */
17
+ async get(slug: string): Promise<Bucket> {
18
+ return this.http.get<Bucket>(`/buckets/${encodeURIComponent(slug)}`)
19
+ }
20
+
21
+ /** Create a new bucket. Slug is auto-derived from name if omitted. */
22
+ async create(params: CreateBucketParams): Promise<Bucket> {
23
+ return this.http.post<Bucket>("/buckets", params)
24
+ }
25
+
26
+ /**
27
+ * Update a bucket's name, description, or visibility. Toggling
28
+ * `isPublic` cascades to every existing file in the bucket (S3 ACL +
29
+ * Media doc); this call can take a while for large buckets.
30
+ */
31
+ async update(slug: string, params: UpdateBucketParams): Promise<Bucket> {
32
+ return this.http.patch<Bucket>(
33
+ `/buckets/${encodeURIComponent(slug)}`,
34
+ params,
35
+ )
36
+ }
37
+
38
+ /**
39
+ * Delete a bucket. Fails with 409 if the bucket has any files unless
40
+ * `force: true` is passed — then every file is purged from storage
41
+ * before the bucket is removed.
42
+ */
43
+ async delete(slug: string, opts?: { force?: boolean }): Promise<void> {
44
+ const query = opts?.force ? { force: "true" } : undefined
45
+ await this.http.del<{ deleted: boolean }>(
46
+ `/buckets/${encodeURIComponent(slug)}`,
47
+ query,
48
+ )
49
+ }
50
+ }
@@ -0,0 +1,16 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { Domain } from "../types"
3
+
4
+ export class Domains {
5
+ constructor(private http: HttpClient) {}
6
+
7
+ /** List all verified domains for the company */
8
+ async list(): Promise<Domain[]> {
9
+ return this.http.get<Domain[]>("/domains")
10
+ }
11
+
12
+ /** Get a single domain by ID */
13
+ async get(id: string): Promise<Domain> {
14
+ return this.http.get<Domain>(`/domains/${encodeURIComponent(id)}`)
15
+ }
16
+ }
@@ -0,0 +1,99 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ MessageSummary,
4
+ MessageDetail,
5
+ Mailbox,
6
+ InboxListParams,
7
+ } from "../types"
8
+
9
+ export class Inbox {
10
+ constructor(private http: HttpClient) {}
11
+
12
+ /** List messages in a mailbox folder */
13
+ async list(params?: InboxListParams): Promise<MessageSummary[]> {
14
+ const query: Record<string, unknown> = {}
15
+ if (params?.mailbox) query.mailbox = params.mailbox
16
+ if (params?.folder) query.folder = params.folder
17
+ if (params?.page) query.page = params.page
18
+ if (params?.limit) query.limit = params.limit
19
+ if (params?.unreadOnly) query.unreadOnly = "true"
20
+ return this.http.get<MessageSummary[]>("/inbox", query)
21
+ }
22
+
23
+ /** Get a single message detail */
24
+ async get(
25
+ uid: number,
26
+ options?: { mailbox?: string; folder?: string },
27
+ ): Promise<MessageDetail> {
28
+ const query: Record<string, unknown> = {}
29
+ if (options?.mailbox) query.mailbox = options.mailbox
30
+ if (options?.folder) query.folder = options.folder
31
+ return this.http.get<MessageDetail>(`/inbox/${uid}`, query)
32
+ }
33
+
34
+ /** List IMAP folders (mailboxes) for a given email account */
35
+ async listFolders(mailbox?: string): Promise<Mailbox[]> {
36
+ const query: Record<string, unknown> = {}
37
+ if (mailbox) query.mailbox = mailbox
38
+ return this.http.get<Mailbox[]>("/inbox/mailboxes", query)
39
+ }
40
+
41
+ /** Get thread messages by subject */
42
+ async getThread(
43
+ subject: string,
44
+ mailbox?: string,
45
+ ): Promise<(MessageDetail & { folder: string })[]> {
46
+ const query: Record<string, unknown> = { subject }
47
+ if (mailbox) query.mailbox = mailbox
48
+ return this.http.get<(MessageDetail & { folder: string })[]>(
49
+ "/inbox/thread",
50
+ query,
51
+ )
52
+ }
53
+
54
+ /** Mark a message as read */
55
+ async markAsRead(
56
+ uid: number,
57
+ options?: { mailbox?: string; folder?: string },
58
+ ): Promise<void> {
59
+ await this.http.post(`/inbox/${uid}/read`, {
60
+ mailbox: options?.mailbox,
61
+ folder: options?.folder,
62
+ })
63
+ }
64
+
65
+ /** Mark a message as unread */
66
+ async markAsUnread(
67
+ uid: number,
68
+ options?: { mailbox?: string; folder?: string },
69
+ ): Promise<void> {
70
+ await this.http.del(`/inbox/${uid}/read`, {
71
+ mailbox: options?.mailbox,
72
+ folder: options?.folder,
73
+ })
74
+ }
75
+
76
+ /** Move a message to another folder */
77
+ async move(
78
+ uid: number,
79
+ to: string,
80
+ options?: { from?: string; mailbox?: string },
81
+ ): Promise<void> {
82
+ await this.http.post(`/inbox/${uid}/move`, {
83
+ to,
84
+ from: options?.from,
85
+ mailbox: options?.mailbox,
86
+ })
87
+ }
88
+
89
+ /** Delete a message */
90
+ async delete(
91
+ uid: number,
92
+ options?: { mailbox?: string; folder?: string },
93
+ ): Promise<void> {
94
+ const query: Record<string, unknown> = {}
95
+ if (options?.mailbox) query.mailbox = options.mailbox
96
+ if (options?.folder) query.folder = options.folder
97
+ await this.http.del(`/inbox/${uid}`, query)
98
+ }
99
+ }
@@ -0,0 +1,11 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { MailboxUser } from "../types"
3
+
4
+ export class Mailboxes {
5
+ constructor(private http: HttpClient) {}
6
+
7
+ /** List all mailbox accounts for the company */
8
+ async list(): Promise<MailboxUser[]> {
9
+ return this.http.get<MailboxUser[]>("/mailboxes")
10
+ }
11
+ }
@@ -0,0 +1,115 @@
1
+ import type { HttpClient } from "../http"
2
+ import type {
3
+ Media,
4
+ MediaListParams,
5
+ MediaListResult,
6
+ UploadMediaParams,
7
+ } from "../types"
8
+
9
+ export class MediaResource {
10
+ constructor(private http: HttpClient) {}
11
+
12
+ /** List files in a bucket (paginated). */
13
+ async list(
14
+ bucketSlug: string,
15
+ params?: MediaListParams,
16
+ ): Promise<MediaListResult> {
17
+ return this.http.get<MediaListResult>(
18
+ `/buckets/${encodeURIComponent(bucketSlug)}/media`,
19
+ params as Record<string, unknown> | undefined,
20
+ )
21
+ }
22
+
23
+ /** Get a single media record. */
24
+ async get(bucketSlug: string, mediaId: string): Promise<Media> {
25
+ return this.http.get<Media>(
26
+ `/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}`,
27
+ )
28
+ }
29
+
30
+ /**
31
+ * Upload a file to a bucket. Works in Node and the browser: `body`
32
+ * is a `Blob` or `File`. Returns the full serialized media record
33
+ * (with `url`, `downloadUrl`, image thumbnails when applicable).
34
+ *
35
+ * @example Browser
36
+ * ```ts
37
+ * const file = input.files[0]
38
+ * const media = await sentroy.media.upload("my-bucket", { body: file })
39
+ * console.log(media.url)
40
+ * ```
41
+ *
42
+ * @example Node 18+
43
+ * ```ts
44
+ * import { openAsBlob } from "node:fs"
45
+ * const blob = await openAsBlob("./photo.jpg")
46
+ * const media = await sentroy.media.upload("my-bucket", {
47
+ * body: blob,
48
+ * filename: "photo.jpg",
49
+ * })
50
+ * ```
51
+ */
52
+ async upload(
53
+ bucketSlug: string,
54
+ params: UploadMediaParams,
55
+ ): Promise<Media> {
56
+ const form = new FormData()
57
+ const filename =
58
+ params.filename ||
59
+ (params.body as File & { name?: string }).name ||
60
+ "upload.bin"
61
+ form.append("file", params.body, filename)
62
+ if (params.folder) form.append("folder", params.folder)
63
+ if (params.isPublic !== undefined) {
64
+ form.append("public", params.isPublic ? "true" : "false")
65
+ }
66
+ if (params.alt) form.append("alt", params.alt)
67
+ if (params.caption) form.append("caption", params.caption)
68
+ if (params.tags?.length) form.append("tags", params.tags.join(","))
69
+
70
+ return this.http.postForm<Media>(
71
+ `/buckets/${encodeURIComponent(bucketSlug)}/media`,
72
+ form,
73
+ )
74
+ }
75
+
76
+ /**
77
+ * Delete a file. Cascades through the CDN: S3 objects (original +
78
+ * thumbnails) are removed, then the Media record. If any S3 delete
79
+ * fails the record is kept so you can retry.
80
+ */
81
+ async delete(bucketSlug: string, mediaId: string): Promise<void> {
82
+ await this.http.del<{ deleted: boolean }>(
83
+ `/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}`,
84
+ )
85
+ }
86
+
87
+ /**
88
+ * Download a media file. For private buckets this is the only way to
89
+ * fetch the bytes since the CDN public URL won't serve them; the
90
+ * storage app proxies through after auth.
91
+ *
92
+ * Pass `quality` to request a pre-generated thumbnail variant (e.g.
93
+ * `quality: 500` for 500px wide). Falls back to the original if the
94
+ * variant doesn't exist for that file.
95
+ */
96
+ async download(
97
+ bucketSlug: string,
98
+ mediaId: string,
99
+ opts?: { quality?: number | "original" },
100
+ ): Promise<Blob> {
101
+ const quality = opts?.quality
102
+ const query =
103
+ quality && quality !== "original"
104
+ ? { quality: String(quality) }
105
+ : undefined
106
+ const res = await this.http.fetchRaw(
107
+ `/buckets/${encodeURIComponent(bucketSlug)}/media/${encodeURIComponent(mediaId)}/download`,
108
+ { query },
109
+ )
110
+ if (!res.ok) {
111
+ throw new Error(`Download failed with status ${res.status}`)
112
+ }
113
+ return res.blob()
114
+ }
115
+ }
@@ -0,0 +1,11 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { SendParams, SendResult } from "../types"
3
+
4
+ export class Send {
5
+ constructor(private http: HttpClient) {}
6
+
7
+ /** Send an email */
8
+ async email(params: SendParams): Promise<SendResult> {
9
+ return this.http.post<SendResult>("/send", params)
10
+ }
11
+ }
@@ -0,0 +1,28 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { StorageQuota, StorageUsage } from "../types"
3
+
4
+ /**
5
+ * Storage observability — read-only quota + breakdown endpoints. Bucket
6
+ * and media CRUD lives on `client.buckets` / `client.media`.
7
+ */
8
+ export class Storage {
9
+ constructor(private http: HttpClient) {}
10
+
11
+ /**
12
+ * Plan storage quota for the company. `used` reflects bucket totals,
13
+ * `mailUsed` what the mail product has stored under the same plan
14
+ * pool, and `limit: 0` means unlimited.
15
+ */
16
+ async quota(): Promise<StorageQuota> {
17
+ return this.http.get<StorageQuota>("/storage-quota")
18
+ }
19
+
20
+ /**
21
+ * Combined dashboard payload — plan quota + per-bucket byte/file
22
+ * counts + per-type aggregation across the company. Single round-trip
23
+ * intended for usage UIs.
24
+ */
25
+ async usage(): Promise<StorageUsage> {
26
+ return this.http.get<StorageUsage>("/usage")
27
+ }
28
+ }
@@ -0,0 +1,16 @@
1
+ import type { HttpClient } from "../http"
2
+ import type { Template } from "../types"
3
+
4
+ export class Templates {
5
+ constructor(private http: HttpClient) {}
6
+
7
+ /** List all templates */
8
+ async list(): Promise<Template[]> {
9
+ return this.http.get<Template[]>("/templates")
10
+ }
11
+
12
+ /** Get a single template by ID */
13
+ async get(id: string): Promise<Template> {
14
+ return this.http.get<Template>(`/templates/${encodeURIComponent(id)}`)
15
+ }
16
+ }