Skip to main content

hey_sdk/services/
world.rs

1//! HEY World — the blog you write by sending an email.
2//!
3//! None of it is JSON: a post is created by emailing world@hey.com, an edit answers a
4//! redirect, and the subscriber list is a CSV stream.
5
6use std::borrow::Cow;
7
8use bytes::{Bytes, BytesMut};
9use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, utf8_percent_encode};
10
11use crate::client::Client;
12use crate::error::Error;
13use crate::http::Method;
14use crate::observability::OperationInfo;
15use crate::services::write_info;
16
17/// The recipient that turns a message into a HEY World post.
18pub const WORLD_ADDRESS: &str = "world@hey.com";
19
20/// Where a published message lands, and what the token naming the post follows.
21const POST_PATH: &str = "/world/posts/";
22
23/// The part a subscriber import is read from.
24const IMPORT_PART: &str = "world_list_import[source]";
25
26/// The file name an import falls back to when the caller names none.
27const DEFAULT_IMPORT_FILENAME: &str = "subscribers.csv";
28
29/// A path parameter, escaped as every modelled route escapes one. A list is named by its
30/// author's email address, so the `@` goes out as `%40`.
31const PATH_SEGMENT: &AsciiSet = &NON_ALPHANUMERIC
32    .remove(b'-')
33    .remove(b'_')
34    .remove(b'.')
35    .remove(b'~');
36
37/// The HEY World service: publishing posts and keeping a list's subscribers.
38pub struct World<'a> {
39    client: &'a Client,
40}
41
42impl Client {
43    /// The HEY World service.
44    pub fn world(&self) -> World<'_> {
45        World::new(self)
46    }
47}
48
49impl<'a> World<'a> {
50    pub(crate) fn new(client: &'a Client) -> World<'a> {
51        World { client }
52    }
53
54    /// The client this service sends through.
55    pub fn client(&self) -> &'a Client {
56        self.client
57    }
58
59    /// Writes a HEY World post by sending a message to [`WORLD_ADDRESS`], and answers the
60    /// post's token — the handle [`World::update_post`] and [`World::delete_post`] take.
61    pub async fn publish(&self, subject: &str, content: &str) -> Result<String, Error> {
62        let sender_id = self.client.default_sender_id().await?.to_string();
63        let mut operation = self.client.form(Method::POST, "/messages")?;
64        operation.info(write_info("World", "PublishWorldPost", "world_post", None));
65        operation.form(&[
66            ("acting_sender_id", sender_id.as_str()),
67            ("message[subject]", subject),
68            ("message[content]", content),
69            ("entry[addressed][directly]", WORLD_ADDRESS),
70            ("entry[status]", "active"),
71        ]);
72
73        let sent = self.client.send_form(operation).await?;
74        let location = sent.location.unwrap_or_default();
75        post_token(&location).ok_or_else(|| {
76            Error::api(
77                0,
78                format!(
79                    "the message was sent but did not become a HEY World post (landed on {location:?})"
80                ),
81            )
82        })
83    }
84
85    /// Edits a published post. An empty subject or body is left off the wire, and HEY leaves
86    /// what a request does not name alone.
87    pub async fn update_post(
88        &self,
89        token: &str,
90        subject: &str,
91        content: &str,
92    ) -> Result<(), Error> {
93        let mut fields = Vec::new();
94        if !subject.is_empty() {
95            fields.push(("world_post[subject]", subject));
96        }
97        if !content.is_empty() {
98            fields.push(("world_post[content]", content));
99        }
100
101        let mut operation = self.client.form(Method::PATCH, &post_path(token))?;
102        operation.info(write_info("World", "UpdateWorldPost", "world_post", None));
103        operation.form(&fields);
104        self.client.send_unit(operation).await
105    }
106
107    /// Takes a post off HEY World.
108    pub async fn delete_post(&self, token: &str) -> Result<(), Error> {
109        let mut operation = self.client.form(Method::DELETE, &post_path(token))?;
110        operation.info(write_info("World", "DeleteWorldPost", "world_post", None));
111        self.client.send_unit(operation).await
112    }
113
114    /// The confirmed subscribers of a list as CSV, with the columns `email_address` and
115    /// `subscribed_at`. The list is named by its author's email address.
116    pub async fn export_subscribers(&self, list_email_address: &str) -> Result<Bytes, Error> {
117        let path = format!("/world/lists/{}/export.csv", escape(list_email_address));
118        let mut operation = self.client.csv(&path)?;
119        operation.info(OperationInfo {
120            service: Cow::Borrowed("World"),
121            operation: Cow::Borrowed("ExportWorldSubscribers"),
122            resource_type: Cow::Borrowed("world_list"),
123            is_mutation: false,
124            resource_id: None,
125        });
126        Ok(self.client.execute(operation).await?.body)
127    }
128
129    /// Uploads a CSV of subscribers to a list. A blank `filename` becomes `subscribers.csv`,
130    /// and one that does not already end in `.csv` gets it added: HEY reads the import by its
131    /// extension.
132    pub async fn import_subscribers(
133        &self,
134        list_email_address: &str,
135        filename: &str,
136        csv: &[u8],
137    ) -> Result<(), Error> {
138        let (content_type, body) = subscriber_import_body(filename, csv);
139        let mut operation = self.client.form(
140            Method::POST,
141            &format!("/world/lists/{}/imports", escape(list_email_address)),
142        )?;
143        operation.info(write_info(
144            "World",
145            "ImportWorldSubscribers",
146            "world_list",
147            None,
148        ));
149        operation.multipart(content_type, body);
150        self.client.send_unit(operation).await
151    }
152}
153
154/// The token out of the location a publish redirected to, as Go's `/world/posts/([0-9a-f]+)`
155/// reads it: the first `/world/posts/` followed by at least one hex digit, and the run of hex
156/// digits after it. A message that landed anywhere else names no token.
157fn post_token(location: &str) -> Option<String> {
158    location
159        .match_indices(POST_PATH)
160        .map(|(at, _)| hex_run(&location[at + POST_PATH.len()..]))
161        .find(|token| !token.is_empty())
162}
163
164fn hex_run(text: &str) -> String {
165    text.chars()
166        .take_while(|character| matches!(character, '0'..='9' | 'a'..='f'))
167        .collect()
168}
169
170fn post_path(token: &str) -> String {
171    format!("{POST_PATH}{}", escape(token))
172}
173
174fn escape(value: &str) -> String {
175    utf8_percent_encode(value, PATH_SEGMENT).to_string()
176}
177
178/// The CSV wrapped in the multipart form the import endpoint expects, and the content type
179/// naming the boundary it was built with.
180fn subscriber_import_body(filename: &str, csv: &[u8]) -> (String, Bytes) {
181    let boundary = boundary();
182    let mut body = BytesMut::from(
183        format!(
184            "--{boundary}\r\nContent-Disposition: form-data; name=\"{IMPORT_PART}\"; filename=\"{}\"\r\nContent-Type: application/octet-stream\r\n\r\n",
185            escape_quotes(&import_filename(filename))
186        )
187        .as_bytes(),
188    );
189    body.extend_from_slice(csv);
190    body.extend_from_slice(format!("\r\n--{boundary}--\r\n").as_bytes());
191    (
192        format!("multipart/form-data; boundary={boundary}"),
193        body.freeze(),
194    )
195}
196
197fn boundary() -> String {
198    format!("{:032x}", rand::random::<u128>())
199}
200
201#[allow(clippy::case_sensitive_file_extension_comparisons)] // The same case-sensitive suffix check Go makes, so both SDKs send the same filename.
202fn import_filename(filename: &str) -> String {
203    if filename.is_empty() {
204        DEFAULT_IMPORT_FILENAME.to_string()
205    } else if filename.ends_with(".csv") {
206        filename.to_string()
207    } else {
208        format!("{filename}.csv")
209    }
210}
211
212/// A quote or a backslash would end the header field early, so both are escaped the way Go's
213/// `mime/multipart` escapes them.
214fn escape_quotes(text: &str) -> String {
215    text.replace('\\', "\\\\").replace('"', "\\\"")
216}