Line data Source code
1 : #include "compose_service.h"
2 : #include "mime_util.h"
3 : #include <stdio.h>
4 : #include <stdlib.h>
5 : #include <string.h>
6 : #include <time.h>
7 : #include <unistd.h>
8 : #include <sys/stat.h>
9 : #include <libgen.h>
10 : #include <ctype.h>
11 :
12 : /**
13 : * @file compose_service.c
14 : * @brief RFC 2822 message builder and reply metadata extractor.
15 : */
16 :
17 : /* ── Internal helpers ────────────────────────────────────────────────── */
18 :
19 : /** Convert LF-terminated body to CRLF. Returns heap string; caller frees. */
20 31 : static char *lf_to_crlf(const char *body) {
21 31 : if (!body) return strdup("");
22 : /* Count LFs to pre-size the output */
23 31 : size_t lf_count = 0;
24 3440 : for (const char *p = body; *p; p++)
25 3409 : if (*p == '\n') lf_count++;
26 31 : size_t blen = strlen(body);
27 31 : char *out = malloc(blen + lf_count + 1);
28 31 : if (!out) return NULL;
29 31 : char *q = out;
30 3440 : for (const char *p = body; *p; p++) {
31 3409 : if (*p == '\n' && (p == body || *(p-1) != '\r'))
32 163 : *q++ = '\r';
33 3409 : *q++ = *p;
34 : }
35 31 : *q = '\0';
36 31 : return out;
37 : }
38 :
39 : /** Trim leading/trailing whitespace in-place (returns pointer into s). */
40 18 : static char *trim_ws(char *s) {
41 18 : while (*s == ' ' || *s == '\t' || *s == '\r' || *s == '\n') s++;
42 18 : char *end = s + strlen(s);
43 18 : while (end > s && (*(end-1) == ' ' || *(end-1) == '\t' ||
44 18 : *(end-1) == '\r' || *(end-1) == '\n'))
45 0 : end--;
46 18 : *end = '\0';
47 18 : return s;
48 : }
49 :
50 : /* ── Attachment helpers ───────────────────────────────────────────────── */
51 :
52 : /** Base64 encoding table. */
53 : static const char b64tab[] =
54 : "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
55 :
56 : /**
57 : * Base64-encode `src` of `srclen` bytes into `dst`.
58 : * Lines are wrapped at 76 characters with CRLF.
59 : * `dst` must be large enough: ((srclen+2)/3)*4 + (((srclen+2)/3)*4/76+2)*2 + 4 bytes.
60 : * Returns number of bytes written (excluding NUL).
61 : */
62 3 : static size_t base64_encode(const unsigned char *src, size_t srclen,
63 : char *dst)
64 : {
65 3 : size_t col = 0;
66 3 : char *d = dst;
67 :
68 22 : for (size_t i = 0; i < srclen; i += 3) {
69 19 : unsigned int b0 = src[i];
70 19 : unsigned int b1 = (i + 1 < srclen) ? src[i + 1] : 0;
71 19 : unsigned int b2 = (i + 2 < srclen) ? src[i + 2] : 0;
72 19 : size_t avail = srclen - i; /* bytes remaining in this group */
73 :
74 19 : *d++ = b64tab[(b0 >> 2) & 0x3F];
75 19 : *d++ = b64tab[((b0 & 0x03) << 4) | ((b1 >> 4) & 0x0F)];
76 19 : *d++ = (avail >= 2) ? b64tab[((b1 & 0x0F) << 2) | ((b2 >> 6) & 0x03)] : '=';
77 19 : *d++ = (avail >= 3) ? b64tab[b2 & 0x3F] : '=';
78 19 : col += 4;
79 :
80 19 : if (col >= 76) {
81 0 : *d++ = '\r';
82 0 : *d++ = '\n';
83 0 : col = 0;
84 : }
85 : }
86 :
87 : /* Trailing CRLF if we have a partial line */
88 3 : if (col > 0) {
89 3 : *d++ = '\r';
90 3 : *d++ = '\n';
91 : }
92 :
93 3 : *d = '\0';
94 3 : return (size_t)(d - dst);
95 : }
96 :
97 : /**
98 : * Infer MIME content type from file extension (case-insensitive).
99 : */
100 3 : static const char *infer_mime_type(const char *filename)
101 : {
102 3 : const char *dot = strrchr(filename, '.');
103 3 : if (!dot) return "application/octet-stream";
104 :
105 : /* Copy extension to lowercase */
106 : char ext[32];
107 : size_t i;
108 12 : for (i = 0; i < sizeof(ext) - 1 && dot[1 + i]; i++)
109 9 : ext[i] = (char)tolower((unsigned char)dot[1 + i]);
110 3 : ext[i] = '\0';
111 :
112 3 : if (strcmp(ext, "pdf") == 0) return "application/pdf";
113 2 : if (strcmp(ext, "txt") == 0) return "text/plain";
114 0 : if (strcmp(ext, "html") == 0) return "text/html";
115 0 : if (strcmp(ext, "htm") == 0) return "text/html";
116 0 : if (strcmp(ext, "jpg") == 0) return "image/jpeg";
117 0 : if (strcmp(ext, "jpeg") == 0) return "image/jpeg";
118 0 : if (strcmp(ext, "png") == 0) return "image/png";
119 0 : if (strcmp(ext, "gif") == 0) return "image/gif";
120 0 : if (strcmp(ext, "zip") == 0) return "application/zip";
121 0 : if (strcmp(ext, "gz") == 0) return "application/gzip";
122 0 : if (strcmp(ext, "csv") == 0) return "text/csv";
123 0 : if (strcmp(ext, "json") == 0) return "application/json";
124 0 : if (strcmp(ext, "xml") == 0) return "application/xml";
125 0 : return "application/octet-stream";
126 : }
127 :
128 : /* ── Public API ──────────────────────────────────────────────────────── */
129 :
130 31 : int compose_build_message(const ComposeParams *p, char **out, size_t *outlen) {
131 31 : if (!p || !p->from || !p->to || !p->subject || !out || !outlen)
132 0 : return -1;
133 :
134 : /* Date header in RFC 2822 format */
135 31 : time_t now = time(NULL);
136 : struct tm tm_local;
137 31 : localtime_r(&now, &tm_local);
138 : char date_str[64];
139 31 : strftime(date_str, sizeof(date_str), "%a, %d %b %Y %H:%M:%S %z", &tm_local);
140 :
141 : /* Message-ID */
142 31 : char hostname[256] = "localhost";
143 31 : gethostname(hostname, sizeof(hostname));
144 : char msgid[512];
145 31 : snprintf(msgid, sizeof(msgid), "<%ld.%d@%s>", (long)now, (int)getpid(), hostname);
146 :
147 : /* In-Reply-To header (replies only) */
148 31 : char reply_hdr[512] = "";
149 31 : if (p->reply_to_msg_id && p->reply_to_msg_id[0])
150 0 : snprintf(reply_hdr, sizeof(reply_hdr),
151 : "In-Reply-To: %s\r\nReferences: %s\r\n",
152 0 : p->reply_to_msg_id, p->reply_to_msg_id);
153 :
154 : /* Cc/Bcc headers (optional) */
155 31 : char cc_hdr[600] = "";
156 31 : if (p->cc && p->cc[0])
157 4 : snprintf(cc_hdr, sizeof(cc_hdr), "Cc: %s\r\n", p->cc);
158 :
159 31 : char bcc_hdr[600] = "";
160 31 : if (p->bcc && p->bcc[0])
161 2 : snprintf(bcc_hdr, sizeof(bcc_hdr), "Bcc: %s\r\n", p->bcc);
162 :
163 : /* Convert body line endings to CRLF */
164 31 : char *body_crlf = lf_to_crlf(p->body ? p->body : "");
165 31 : if (!body_crlf) return -1;
166 :
167 : /* ── Multipart path (attachments present) ────────────────────────── */
168 31 : if (p->attachments != NULL && p->attach_count > 0) {
169 :
170 : /* 1. Validate all attachments before building anything */
171 8 : for (int ai = 0; ai < p->attach_count; ai++) {
172 : struct stat st;
173 6 : if (stat(p->attachments[ai], &st) != 0 || !S_ISREG(st.st_mode)) {
174 2 : free(body_crlf);
175 2 : *out = NULL;
176 3 : return -1;
177 : }
178 4 : if (st.st_size > 26214400) { /* 25 MB */
179 1 : free(body_crlf);
180 1 : *out = NULL;
181 1 : return -1;
182 : }
183 : }
184 :
185 : /* 2. Generate unique MIME boundary — include a monotonic counter so
186 : * two calls in the same second get different boundaries. */
187 : static unsigned int boundary_seq = 0;
188 2 : unsigned int seq = __atomic_add_fetch(&boundary_seq, 1, __ATOMIC_RELAXED);
189 : char boundary[96];
190 4 : snprintf(boundary, sizeof(boundary), "=_%d_%ld_%u",
191 2 : (int)getpid(), (long)now, seq);
192 :
193 : /* 3. Build multipart/mixed message using open_memstream */
194 2 : char *buf = NULL;
195 2 : size_t bufsz = 0;
196 2 : FILE *ms = open_memstream(&buf, &bufsz);
197 2 : if (!ms) {
198 0 : free(body_crlf);
199 0 : return -1;
200 : }
201 :
202 : /* Top-level headers */
203 2 : fprintf(ms, "Date: %s\r\n", date_str);
204 2 : fprintf(ms, "From: %s\r\n", p->from);
205 2 : fprintf(ms, "To: %s\r\n", p->to);
206 2 : if (cc_hdr[0]) fprintf(ms, "%s", cc_hdr);
207 2 : if (bcc_hdr[0]) fprintf(ms, "%s", bcc_hdr);
208 2 : fprintf(ms, "Subject: %s\r\n", p->subject);
209 2 : fprintf(ms, "Message-ID: %s\r\n", msgid);
210 2 : if (reply_hdr[0]) fprintf(ms, "%s", reply_hdr);
211 2 : fprintf(ms, "MIME-Version: 1.0\r\n");
212 2 : fprintf(ms, "Content-Type: multipart/mixed; boundary=\"%s\"\r\n", boundary);
213 2 : fprintf(ms, "\r\n");
214 :
215 : /* First MIME part: text/plain body */
216 2 : fprintf(ms, "--%s\r\n", boundary);
217 2 : fprintf(ms, "Content-Type: text/plain; charset=UTF-8\r\n");
218 2 : fprintf(ms, "Content-Transfer-Encoding: 8bit\r\n");
219 2 : fprintf(ms, "\r\n");
220 2 : fprintf(ms, "%s", body_crlf);
221 2 : fprintf(ms, "\r\n");
222 :
223 : /* Attachment parts */
224 5 : for (int ai = 0; ai < p->attach_count; ai++) {
225 3 : const char *fpath = p->attachments[ai];
226 :
227 : /* basename — work on a copy since basename() may modify */
228 : char path_copy[4096];
229 3 : snprintf(path_copy, sizeof(path_copy), "%s", fpath);
230 3 : const char *fname = basename(path_copy);
231 :
232 3 : const char *mime_type = infer_mime_type(fname);
233 :
234 : /* Read file contents */
235 3 : FILE *f = fopen(fpath, "rb");
236 3 : if (!f) {
237 0 : fclose(ms);
238 0 : free(buf);
239 0 : free(body_crlf);
240 0 : *out = NULL;
241 0 : return -1;
242 : }
243 : struct stat st;
244 3 : stat(fpath, &st);
245 3 : size_t fsize = (size_t)st.st_size;
246 3 : unsigned char *fdata = malloc(fsize);
247 3 : if (!fdata) {
248 0 : fclose(f);
249 0 : fclose(ms);
250 0 : free(buf);
251 0 : free(body_crlf);
252 0 : return -1;
253 : }
254 3 : size_t nread = fread(fdata, 1, fsize, f);
255 3 : fclose(f);
256 :
257 : /* Base64 encode: worst case = ceil(nread/3)*4 + 2*(ceil(nread*4/3)/76+2) */
258 3 : size_t b64_max = ((nread + 2) / 3) * 4 + ((((nread + 2) / 3) * 4) / 76 + 2) * 2 + 4;
259 3 : char *b64 = malloc(b64_max + 1);
260 3 : if (!b64) {
261 0 : free(fdata);
262 0 : fclose(ms);
263 0 : free(buf);
264 0 : free(body_crlf);
265 0 : return -1;
266 : }
267 3 : base64_encode(fdata, nread, b64);
268 3 : free(fdata);
269 :
270 3 : fprintf(ms, "--%s\r\n", boundary);
271 3 : fprintf(ms, "Content-Type: %s\r\n", mime_type);
272 3 : fprintf(ms, "Content-Transfer-Encoding: base64\r\n");
273 3 : fprintf(ms, "Content-Disposition: attachment; filename=\"%s\"\r\n", fname);
274 3 : fprintf(ms, "\r\n");
275 3 : fprintf(ms, "%s", b64);
276 3 : fprintf(ms, "\r\n");
277 3 : free(b64);
278 : }
279 :
280 : /* Closing boundary */
281 2 : fprintf(ms, "--%s--\r\n", boundary);
282 :
283 2 : fclose(ms);
284 2 : free(body_crlf);
285 :
286 2 : *out = buf;
287 2 : *outlen = bufsz;
288 2 : return 0;
289 : }
290 :
291 : /* ── Plain text path (no attachments) ───────────────────────────── */
292 :
293 : /* Assemble message */
294 26 : char *msg = NULL;
295 26 : int len = asprintf(&msg,
296 : "Date: %s\r\n"
297 : "From: %s\r\n"
298 : "To: %s\r\n"
299 : "%s"
300 : "%s"
301 : "Subject: %s\r\n"
302 : "Message-ID: %s\r\n"
303 : "%s"
304 : "MIME-Version: 1.0\r\n"
305 : "Content-Type: text/plain; charset=UTF-8\r\n"
306 : "Content-Transfer-Encoding: 8bit\r\n"
307 : "\r\n"
308 : "%s",
309 : date_str,
310 26 : p->from,
311 26 : p->to,
312 : cc_hdr,
313 : bcc_hdr,
314 26 : p->subject,
315 : msgid,
316 : reply_hdr,
317 : body_crlf);
318 26 : free(body_crlf);
319 :
320 26 : if (len < 0 || !msg) return -1;
321 26 : *out = msg;
322 26 : *outlen = (size_t)len;
323 26 : return 0;
324 : }
325 :
326 18 : int compose_extract_reply_meta(const char *raw_msg,
327 : char **reply_to_out,
328 : char **subject_out,
329 : char **msg_id_out) {
330 18 : if (!raw_msg || !reply_to_out || !subject_out || !msg_id_out)
331 0 : return -1;
332 :
333 18 : *reply_to_out = NULL;
334 18 : *subject_out = NULL;
335 18 : *msg_id_out = NULL;
336 :
337 : /* Prefer Reply-To header; fall back to From */
338 18 : char *rt = mime_get_header(raw_msg, "Reply-To");
339 18 : if (!rt || !rt[0]) {
340 18 : free(rt);
341 18 : rt = mime_get_header(raw_msg, "From");
342 : }
343 18 : *reply_to_out = rt ? mime_decode_words(rt) : strdup("");
344 18 : free(rt);
345 :
346 : /* Subject: prefix with "Re: " (strip duplicates) */
347 18 : char *subj_raw = mime_get_header(raw_msg, "Subject");
348 18 : char *subj_dec = subj_raw ? mime_decode_words(subj_raw) : strdup("");
349 18 : free(subj_raw);
350 18 : char *s = subj_dec ? trim_ws(subj_dec) : NULL;
351 : /* Strip all leading "Re: " / "re: " prefixes */
352 18 : while (s && (strncasecmp(s, "re: ", 4) == 0 || strncasecmp(s, "re:", 3) == 0)) {
353 0 : if (strncasecmp(s, "re: ", 4) == 0) s += 4;
354 0 : else s += 3;
355 0 : while (*s == ' ') s++;
356 : }
357 18 : char *subj_out = NULL;
358 18 : if (asprintf(&subj_out, "Re: %s", s ? s : "") < 0)
359 0 : subj_out = strdup("Re: ");
360 18 : free(subj_dec);
361 18 : *subject_out = subj_out;
362 :
363 : /* Message-ID */
364 18 : char *msgid = mime_get_header(raw_msg, "Message-ID");
365 18 : *msg_id_out = msgid ? msgid : strdup("");
366 :
367 18 : return 0;
368 : }
|