files_sdk/sharing/bundles.rs
1//! Bundle (Share Link) operations
2//!
3//! Bundles are the Files.com API term for Share Links. They allow you to share files
4//! and folders with external users via a public URL with granular access controls.
5//!
6//! # Features
7//!
8//! - Create shareable links to files and folders
9//! - Password protection and expiration dates
10//! - Access controls (read, write, preview-only)
11//! - Registration requirements and user tracking
12//! - Email sharing with notifications
13//! - Custom branding and legal clickwrap
14//!
15//! # Example
16//!
17//! ```no_run
18//! use files_sdk::{FilesClient, BundleHandler};
19//!
20//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
21//! let client = FilesClient::builder()
22//! .api_key("your-api-key")
23//! .build()?;
24//!
25//! let handler = BundleHandler::new(client);
26//!
27//! // Create a password-protected share link that expires in 7 days
28//! let bundle = handler.create(
29//! vec!["/reports/quarterly-2024.pdf".to_string()],
30//! Some("secure-password"),
31//! Some("2024-12-31T23:59:59Z"),
32//! None,
33//! Some("Q4 2024 Financial Report"),
34//! Some("Internal sharing only"),
35//! None,
36//! Some(true),
37//! Some("read")
38//! ).await?;
39//!
40//! println!("Share link: {}", bundle.url.unwrap_or_default());
41//!
42//! // Share via email
43//! handler.share(
44//! bundle.id.unwrap(),
45//! vec!["colleague@company.com".to_string()],
46//! Some("Please review the Q4 report")
47//! ).await?;
48//! # Ok(())
49//! # }
50//! ```
51
52use crate::{FilesClient, PaginationInfo, Result};
53use serde::{Deserialize, Serialize};
54use serde_json::json;
55
56/// Bundle permissions enum
57#[derive(Debug, Clone, Serialize, Deserialize)]
58#[serde(rename_all = "snake_case")]
59pub enum BundlePermission {
60 /// Read-only access
61 Read,
62 /// Write-only access (upload)
63 Write,
64 /// Read and write access
65 ReadWrite,
66 /// Full access
67 Full,
68 /// No access
69 None,
70 /// Preview only (no download)
71 PreviewOnly,
72}
73
74/// A Bundle entity (Share Link)
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct BundleEntity {
77 /// Bundle ID
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub id: Option<i64>,
80
81 /// Bundle code - forms the end part of the public URL
82 #[serde(skip_serializing_if = "Option::is_none")]
83 pub code: Option<String>,
84
85 /// Public URL of share link
86 #[serde(skip_serializing_if = "Option::is_none")]
87 pub url: Option<String>,
88
89 /// Public description
90 #[serde(skip_serializing_if = "Option::is_none")]
91 pub description: Option<String>,
92
93 /// Bundle internal note
94 #[serde(skip_serializing_if = "Option::is_none")]
95 pub note: Option<String>,
96
97 /// Is password protected?
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub password_protected: Option<bool>,
100
101 /// Permissions that apply to folders in this share link
102 #[serde(skip_serializing_if = "Option::is_none")]
103 pub permissions: Option<String>,
104
105 /// Preview only mode
106 #[serde(skip_serializing_if = "Option::is_none")]
107 pub preview_only: Option<bool>,
108
109 /// Require registration to access?
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub require_registration: Option<bool>,
112
113 /// Require explicit share recipient?
114 #[serde(skip_serializing_if = "Option::is_none")]
115 pub require_share_recipient: Option<bool>,
116
117 /// Require logout after each access?
118 #[serde(skip_serializing_if = "Option::is_none")]
119 pub require_logout: Option<bool>,
120
121 /// Legal clickwrap text
122 #[serde(skip_serializing_if = "Option::is_none")]
123 pub clickwrap_body: Option<String>,
124
125 /// ID of clickwrap to use
126 #[serde(skip_serializing_if = "Option::is_none")]
127 pub clickwrap_id: Option<i64>,
128
129 /// Skip name in registration?
130 #[serde(skip_serializing_if = "Option::is_none")]
131 pub skip_name: Option<bool>,
132
133 /// Skip email in registration?
134 #[serde(skip_serializing_if = "Option::is_none")]
135 pub skip_email: Option<bool>,
136
137 /// Skip company in registration?
138 #[serde(skip_serializing_if = "Option::is_none")]
139 pub skip_company: Option<bool>,
140
141 /// Bundle expiration date/time
142 #[serde(skip_serializing_if = "Option::is_none")]
143 pub expires_at: Option<String>,
144
145 /// Date when share becomes accessible
146 #[serde(skip_serializing_if = "Option::is_none")]
147 pub start_access_on_date: Option<String>,
148
149 /// Bundle created at
150 #[serde(skip_serializing_if = "Option::is_none")]
151 pub created_at: Option<String>,
152
153 /// Don't create subfolders for submissions?
154 #[serde(skip_serializing_if = "Option::is_none")]
155 pub dont_separate_submissions_by_folder: Option<bool>,
156
157 /// Maximum number of uses
158 #[serde(skip_serializing_if = "Option::is_none")]
159 pub max_uses: Option<i64>,
160
161 /// Template for submission subfolder paths
162 #[serde(skip_serializing_if = "Option::is_none")]
163 pub path_template: Option<String>,
164
165 /// Timezone for path template timestamps
166 #[serde(skip_serializing_if = "Option::is_none")]
167 pub path_template_time_zone: Option<String>,
168
169 /// Send receipt to uploader?
170 #[serde(skip_serializing_if = "Option::is_none")]
171 pub send_email_receipt_to_uploader: Option<bool>,
172
173 /// Snapshot ID containing bundle contents
174 #[serde(skip_serializing_if = "Option::is_none")]
175 pub snapshot_id: Option<i64>,
176
177 /// Bundle creator user ID
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub user_id: Option<i64>,
180
181 /// Bundle creator username
182 #[serde(skip_serializing_if = "Option::is_none")]
183 pub username: Option<String>,
184
185 /// Associated inbox ID
186 #[serde(skip_serializing_if = "Option::is_none")]
187 pub inbox_id: Option<i64>,
188
189 /// Has associated inbox?
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub has_inbox: Option<bool>,
192
193 /// Prevent folder uploads?
194 #[serde(skip_serializing_if = "Option::is_none")]
195 pub dont_allow_folders_in_uploads: Option<bool>,
196
197 /// Paths included in bundle (not provided when listing)
198 #[serde(skip_serializing_if = "Option::is_none")]
199 pub paths: Option<Vec<String>>,
200
201 /// Page link and button color
202 #[serde(skip_serializing_if = "Option::is_none")]
203 pub color_left: Option<String>,
204
205 /// Top bar link color
206 #[serde(skip_serializing_if = "Option::is_none")]
207 pub color_link: Option<String>,
208
209 /// Page link and button color
210 #[serde(skip_serializing_if = "Option::is_none")]
211 pub color_text: Option<String>,
212
213 /// Top bar background color
214 #[serde(skip_serializing_if = "Option::is_none")]
215 pub color_top: Option<String>,
216
217 /// Top bar text color
218 #[serde(skip_serializing_if = "Option::is_none")]
219 pub color_top_text: Option<String>,
220}
221
222/// Handler for bundle operations
223pub struct BundleHandler {
224 client: FilesClient,
225}
226
227impl BundleHandler {
228 /// Create a new bundle handler
229 pub fn new(client: FilesClient) -> Self {
230 Self { client }
231 }
232
233 /// List all bundles accessible to the current user
234 ///
235 /// Returns a paginated list of bundles (share links) with optional filtering.
236 ///
237 /// # Arguments
238 ///
239 /// * `user_id` - Filter bundles by user ID (None for all accessible bundles)
240 /// * `cursor` - Pagination cursor from previous response
241 /// * `per_page` - Number of results per page (max 10,000)
242 ///
243 /// # Returns
244 ///
245 /// A tuple containing:
246 /// - Vector of `BundleEntity` objects
247 /// - `PaginationInfo` with cursors for next/previous pages
248 ///
249 /// # Example
250 ///
251 /// ```no_run
252 /// use files_sdk::{FilesClient, BundleHandler};
253 ///
254 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
255 /// let client = FilesClient::builder().api_key("key").build()?;
256 /// let handler = BundleHandler::new(client);
257 ///
258 /// // List first page of bundles
259 /// let (bundles, pagination) = handler.list(None, None, Some(50)).await?;
260 ///
261 /// for bundle in bundles {
262 /// println!("Bundle: {} - {}",
263 /// bundle.code.unwrap_or_default(),
264 /// bundle.url.unwrap_or_default());
265 /// }
266 ///
267 /// // Get next page if available
268 /// if let Some(next_cursor) = pagination.cursor_next {
269 /// let (more_bundles, _) = handler.list(None, Some(&next_cursor), Some(50)).await?;
270 /// }
271 /// # Ok(())
272 /// # }
273 /// ```
274 pub async fn list(
275 &self,
276 user_id: Option<i64>,
277 cursor: Option<&str>,
278 per_page: Option<i64>,
279 ) -> Result<(Vec<BundleEntity>, PaginationInfo)> {
280 let mut params = vec![];
281 if let Some(uid) = user_id {
282 params.push(("user_id", uid.to_string()));
283 }
284 if let Some(c) = cursor {
285 params.push(("cursor", c.to_string()));
286 }
287 if let Some(pp) = per_page {
288 params.push(("per_page", pp.to_string()));
289 }
290
291 let query = if params.is_empty() {
292 String::new()
293 } else {
294 format!(
295 "?{}",
296 params
297 .iter()
298 .map(|(k, v)| format!("{}={}", k, v))
299 .collect::<Vec<_>>()
300 .join("&")
301 )
302 };
303
304 let response = self.client.get_raw(&format!("/bundles{}", query)).await?;
305 let bundles: Vec<BundleEntity> = serde_json::from_value(response)?;
306
307 let pagination = PaginationInfo {
308 cursor_next: None,
309 cursor_prev: None,
310 };
311
312 Ok((bundles, pagination))
313 }
314
315 /// Get details of a specific bundle by ID
316 ///
317 /// # Arguments
318 ///
319 /// * `id` - The unique bundle ID
320 ///
321 /// # Returns
322 ///
323 /// A `BundleEntity` with complete bundle information
324 ///
325 /// # Example
326 ///
327 /// ```no_run
328 /// use files_sdk::{FilesClient, BundleHandler};
329 ///
330 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
331 /// let client = FilesClient::builder().api_key("key").build()?;
332 /// let handler = BundleHandler::new(client);
333 ///
334 /// let bundle = handler.get(12345).await?;
335 /// println!("Bundle URL: {}", bundle.url.unwrap_or_default());
336 /// println!("Expires: {}", bundle.expires_at.unwrap_or_default());
337 /// # Ok(())
338 /// # }
339 /// ```
340 pub async fn get(&self, id: i64) -> Result<BundleEntity> {
341 let response = self.client.get_raw(&format!("/bundles/{}", id)).await?;
342 Ok(serde_json::from_value(response)?)
343 }
344
345 /// Create a new bundle (share link)
346 ///
347 /// Creates a shareable link to one or more files or folders with configurable
348 /// access controls and restrictions.
349 ///
350 /// # Arguments
351 ///
352 /// * `paths` - Vector of file/folder paths to share (required, must not be empty)
353 /// * `password` - Password required to access the bundle
354 /// * `expires_at` - ISO 8601 timestamp when bundle expires (e.g., "2024-12-31T23:59:59Z")
355 /// * `max_uses` - Maximum number of times bundle can be accessed
356 /// * `description` - Public description shown to recipients
357 /// * `note` - Private internal note (not shown to recipients)
358 /// * `code` - Custom URL code (auto-generated if not provided)
359 /// * `require_registration` - Require recipients to register before access
360 /// * `permissions` - Access level: "read", "write", "read_write", "full", "preview_only"
361 ///
362 /// # Returns
363 ///
364 /// The newly created `BundleEntity` with URL and access details
365 ///
366 /// # Example
367 ///
368 /// ```no_run
369 /// use files_sdk::{FilesClient, BundleHandler};
370 ///
371 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
372 /// let client = FilesClient::builder().api_key("key").build()?;
373 /// let handler = BundleHandler::new(client);
374 ///
375 /// // Create a simple share link
376 /// let bundle = handler.create(
377 /// vec!["/documents/report.pdf".to_string()],
378 /// None,
379 /// None,
380 /// None,
381 /// Some("Monthly Report"),
382 /// None,
383 /// None,
384 /// Some(false),
385 /// Some("read")
386 /// ).await?;
387 ///
388 /// println!("Share this link: {}", bundle.url.unwrap());
389 /// # Ok(())
390 /// # }
391 /// ```
392 #[allow(clippy::too_many_arguments)]
393 pub async fn create(
394 &self,
395 paths: Vec<String>,
396 password: Option<&str>,
397 expires_at: Option<&str>,
398 max_uses: Option<i64>,
399 description: Option<&str>,
400 note: Option<&str>,
401 code: Option<&str>,
402 require_registration: Option<bool>,
403 permissions: Option<&str>,
404 ) -> Result<BundleEntity> {
405 let mut body = json!({
406 "paths": paths,
407 });
408
409 if let Some(p) = password {
410 body["password"] = json!(p);
411 }
412 if let Some(e) = expires_at {
413 body["expires_at"] = json!(e);
414 }
415 if let Some(m) = max_uses {
416 body["max_uses"] = json!(m);
417 }
418 if let Some(d) = description {
419 body["description"] = json!(d);
420 }
421 if let Some(n) = note {
422 body["note"] = json!(n);
423 }
424 if let Some(c) = code {
425 body["code"] = json!(c);
426 }
427 if let Some(r) = require_registration {
428 body["require_registration"] = json!(r);
429 }
430 if let Some(perm) = permissions {
431 body["permissions"] = json!(perm);
432 }
433
434 let response = self.client.post_raw("/bundles", body).await?;
435 Ok(serde_json::from_value(response)?)
436 }
437
438 /// Update an existing bundle's settings
439 ///
440 /// Modifies bundle properties such as password, expiration, and description.
441 /// Only provided fields will be updated; omitted fields remain unchanged.
442 ///
443 /// # Arguments
444 ///
445 /// * `id` - Bundle ID to update
446 /// * `password` - New password (pass empty string to remove password)
447 /// * `expires_at` - New expiration timestamp
448 /// * `max_uses` - New maximum access count
449 /// * `description` - New public description
450 /// * `note` - New internal note
451 ///
452 /// # Returns
453 ///
454 /// The updated `BundleEntity`
455 ///
456 /// # Example
457 ///
458 /// ```no_run
459 /// use files_sdk::{FilesClient, BundleHandler};
460 ///
461 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
462 /// let client = FilesClient::builder().api_key("key").build()?;
463 /// let handler = BundleHandler::new(client);
464 ///
465 /// // Extend expiration and update description
466 /// let bundle = handler.update(
467 /// 12345,
468 /// None,
469 /// Some("2025-06-30T23:59:59Z"),
470 /// None,
471 /// Some("Updated report - extended access"),
472 /// None
473 /// ).await?;
474 /// # Ok(())
475 /// # }
476 /// ```
477 #[allow(clippy::too_many_arguments)]
478 pub async fn update(
479 &self,
480 id: i64,
481 password: Option<&str>,
482 expires_at: Option<&str>,
483 max_uses: Option<i64>,
484 description: Option<&str>,
485 note: Option<&str>,
486 ) -> Result<BundleEntity> {
487 let mut body = json!({});
488
489 if let Some(p) = password {
490 body["password"] = json!(p);
491 }
492 if let Some(e) = expires_at {
493 body["expires_at"] = json!(e);
494 }
495 if let Some(m) = max_uses {
496 body["max_uses"] = json!(m);
497 }
498 if let Some(d) = description {
499 body["description"] = json!(d);
500 }
501 if let Some(n) = note {
502 body["note"] = json!(n);
503 }
504
505 let response = self
506 .client
507 .patch_raw(&format!("/bundles/{}", id), body)
508 .await?;
509 Ok(serde_json::from_value(response)?)
510 }
511
512 /// Delete a bundle permanently
513 ///
514 /// Removes the bundle and revokes access via its share link. This operation
515 /// cannot be undone.
516 ///
517 /// # Arguments
518 ///
519 /// * `id` - Bundle ID to delete
520 ///
521 /// # Example
522 ///
523 /// ```no_run
524 /// use files_sdk::{FilesClient, BundleHandler};
525 ///
526 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
527 /// let client = FilesClient::builder().api_key("key").build()?;
528 /// let handler = BundleHandler::new(client);
529 ///
530 /// handler.delete(12345).await?;
531 /// println!("Bundle deleted successfully");
532 /// # Ok(())
533 /// # }
534 /// ```
535 pub async fn delete(&self, id: i64) -> Result<()> {
536 self.client.delete_raw(&format!("/bundles/{}", id)).await?;
537 Ok(())
538 }
539
540 /// Share a bundle via email
541 ///
542 /// Sends email notifications with the bundle link to specified recipients.
543 /// Recipients receive an email with the share link and optional message.
544 ///
545 /// # Arguments
546 ///
547 /// * `id` - Bundle ID to share
548 /// * `to` - Vector of recipient email addresses
549 /// * `note` - Optional message to include in the email
550 ///
551 /// # Example
552 ///
553 /// ```no_run
554 /// use files_sdk::{FilesClient, BundleHandler};
555 ///
556 /// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
557 /// let client = FilesClient::builder().api_key("key").build()?;
558 /// let handler = BundleHandler::new(client);
559 ///
560 /// // Share with multiple recipients
561 /// handler.share(
562 /// 12345,
563 /// vec![
564 /// "user1@example.com".to_string(),
565 /// "user2@example.com".to_string()
566 /// ],
567 /// Some("Please review these files by Friday")
568 /// ).await?;
569 ///
570 /// println!("Bundle shared successfully");
571 /// # Ok(())
572 /// # }
573 /// ```
574 pub async fn share(&self, id: i64, to: Vec<String>, note: Option<&str>) -> Result<()> {
575 let mut body = json!({
576 "to": to,
577 });
578
579 if let Some(n) = note {
580 body["note"] = json!(n);
581 }
582
583 self.client
584 .post_raw(&format!("/bundles/{}/share", id), body)
585 .await?;
586 Ok(())
587 }
588}
589
590#[cfg(test)]
591mod tests {
592 use super::*;
593
594 #[test]
595 fn test_handler_creation() {
596 let client = FilesClient::builder().api_key("test-key").build().unwrap();
597 let _handler = BundleHandler::new(client);
598 }
599}