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