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}