File Coverage

lib/Mail/Make/Entity.pm
Criterion Covered Total %
statement 301 418 72.0
branch 129 260 49.6
condition 86 152 56.5
subroutine 38 52 73.0
pod 30 32 93.7
total 584 914 63.8


line stmt bran cond sub pod time code
1             ##----------------------------------------------------------------------------
2             ## MIME Email Builder - ~/lib/Mail/Make/Entity.pm
3             ## Version v0.4.1
4             ## Copyright(c) 2026 DEGUEST Pte. Ltd.
5             ## Author: Jacques Deguest <jack@deguest.jp>
6             ## Created 2026/03/02
7             ## Modified 2026/03/18
8             ## All rights reserved
9             ##
10             ##
11             ## This program is free software; you can redistribute it and/or modify it
12             ## under the same terms as Perl itself.
13             ##----------------------------------------------------------------------------
14             package Mail::Make::Entity;
15             BEGIN
16             {
17 7     7   372996 use strict;
  7         14  
  7         282  
18 7     7   64 use warnings;
  7         13  
  7         515  
19 7     7   56 warnings::register_categories( 'Mail::Make' );
20 7     7   37 use parent qw( Module::Generic );
  7         16  
  7         61  
21 7     7   547 use vars qw( $VERSION $EXCEPTION_CLASS $CRLF );
  7         17  
  7         470  
22 7     7   2837 use Data::UUID;
  7         4297  
  7         632  
23 7     7   3695 use Mail::Make::Body::File;
  7         33  
  7         78  
24 7     7   5434 use Mail::Make::Body::InCore;
  7         22  
  7         86  
25 7     7   1932 use Mail::Make::Exception;
  7         12  
  7         43  
26 7     7   5705 use Mail::Make::Headers;
  7         46  
  7         109  
27 7     7   2463 use Mail::Make::Headers::ContentDisposition;
  7         16  
  7         103  
28 7     7   1811 use Mail::Make::Headers::ContentTransferEncoding;
  7         61  
  7         59  
29 7     7   1679 use Mail::Make::Headers::ContentType;
  7         16  
  7         55  
30 7     7   5588 use Mail::Make::Stream;
  7         22  
  7         86  
31 7     7   5875 use Mail::Make::Stream::Base64;
  7         23  
  7         457  
32 7     7   3595 use Mail::Make::Stream::QuotedPrint;
  7         18  
  7         658  
33 7         14 our $CRLF = "\015\012";
34 7         24 our $DEFAULT_MIME_TYPE = 'application/octet-stream';
35 7         12 our $EXCEPTION_CLASS = 'Mail::Make::Exception';
36 7         171 our $VERSION = 'v0.4.1';
37             }
38              
39 7     7   44 use strict;
  7         27  
  7         185  
40 7     7   30 use warnings;
  7         12  
  7         21629  
41              
42             sub init
43             {
44 218     218 1 24859 my $self = shift( @_ );
45 218         1324 $self->{body} = undef; # Mail::Make::Body object
46 218         826 $self->{effective_type} = undef; # cached mime type string
47 218         657 $self->{epilogue} = [];
48 218         792 $self->{headers} = undef; # Mail::Make::Headers object
49 218         780 $self->{is_encoded} = 0;
50 218         849 $self->{preamble} = [];
51 218         671 $self->{_parts} = []; # [ Mail::Make::Entity, ... ]
52 218         867 $self->{_exception_class} = $EXCEPTION_CLASS;
53 218         503 $self->{_init_strict_use_sub} = 1;
54 218 50       1080 $self->SUPER::init( @_ ) || return( $self->pass_error );
55 218         16126 return( $self );
56             }
57              
58             # add_part( $entity )
59             # Appends a Mail::Make::Entity as a sub-part of this entity.
60             sub add_part
61             {
62 43     43 1 13042 my( $self, $part ) = @_;
63 43 100       180 unless( $self->_is_a( $part => 'Mail::Make::Entity' ) )
64             {
65 1         28 return( $self->error( "add_part: argument must be a Mail::Make::Entity" ) );
66             }
67 42         1647 push( @{$self->{_parts}}, $part );
  42         119  
68 42         140 return( $self );
69             }
70              
71             # as_string()
72             # Returns the serialised entity (headers + blank line + body) as a plain string.
73             sub as_string
74             {
75 36     36 1 16418 my $self = shift( @_ );
76 36         140 my $out = '';
77 36 50       1262 open( my $fh, '>:raw', \$out ) ||
78             return( $self->error( "Cannot open in-memory buffer for as_string: $!" ) );
79 36 50       568 $self->print( $fh ) || return( $self->pass_error );
80 36         134 close( $fh );
81 36         725 return( $out );
82             }
83              
84             # Returns the serialised entity as a scalar reference, avoiding a string copy.
85             # Useful for large messages where the caller can pass the ref directly to
86             # print() or write() without materialising a second copy.
87             sub as_string_ref
88             {
89 1     1 1 2366 my $self = shift( @_ );
90 1         1 my $out = '';
91 1 50       14 open( my $fh, '>:raw', \$out ) ||
92             return( $self->error( "Cannot open in-memory buffer for as_string_ref: $!" ) );
93 1 50       3 $self->print( $fh ) || return( $self->pass_error );
94 1         2 close( $fh );
95 1         10 return( \$out );
96             }
97              
98             # body( [$body_object] )
99             # Gets or sets the Mail::Make::Body object.
100             sub body
101             {
102 89     89 1 4304 my $self = shift( @_ );
103 89 100       318 if( @_ )
104             {
105 84         190 my $body = shift( @_ );
106 84 50       298 if( defined( $body ) )
107             {
108 84 50       345 unless( $self->_is_a( $body => 'Mail::Make::Body' ) )
109             {
110 0         0 return( $self->error( "body: argument must be a Mail::Make::Body-derived object" ) );
111             }
112             }
113 84         3598 $self->{body} = $body;
114 84         208 return( $self );
115             }
116 5         32 return( $self->{body} );
117             }
118              
119             # body_as_string()
120             # Returns the encoded body content as a scalar reference.
121             sub body_as_string
122             {
123 0     0 1 0 my $self = shift( @_ );
124 0 0       0 unless( defined( $self->{body} ) )
125             {
126 0         0 return( $self->error( "No body is set on this entity" ) );
127             }
128              
129 0   0     0 my $enc = $self->headers->content_transfer_encoding // '';
130 0 0 0     0 if( CORE::length( $enc ) && !$self->{is_encoded} )
131             {
132 0 0       0 $self->encode_body || return( $self->pass_error );
133             }
134 0         0 return( $self->{body}->as_string );
135             }
136              
137             # build( %params )
138             # Factory / class method: build a single MIME entity from parameters.
139             # This is the key method - it performs strict validation and correct encoding.
140             #
141             # Parameters:
142             # attach Shorthand for 'path'
143             # boundary boundary for multipart/* types (auto-generated if omitted)
144             # charset charset for text/* types
145             # cid Content-ID for inline parts (wrapped in <...> automatically if needed)
146             # data scalar body content
147             # debug debug level (default: 0)
148             # description Content-Description value
149             # disposition inline | attachment (default: none unless filename provided)
150             # encoding CTE (default: auto-suggested)
151             # filename filename for Content-Type name= and Content-Disposition filename=
152             # path file path body content
153             # top boolean - is this the top-level entity? (default: 1)
154             # type MIME type string (default: 'text/plain')
155             sub build
156             {
157 110     110 1 2382362 my $class = shift( @_ );
158             # Support both class and instance call
159 110 50       834 my $self = ref( $class ) ? $class : $class->new;
160 110 50       845 return( $self->pass_error ) if( !$self );
161 110         619 my $opts = $self->_get_args_as_hash( @_ );
162 110         233269 $self->debug( delete( $opts->{debug} ) );
163              
164             # NOTE: 1. Extract and validate parameters
165 110   66     5484 my $has_type = ( exists( $opts->{type} ) && defined( $opts->{type} ) );
166 110   100     915 my $type = lc( delete( $opts->{type} ) // 'text/plain' );
167 110         326 my $charset = delete( $opts->{charset} );
168             my $encoding = defined( $opts->{encoding} )
169 110 100       527 ? lc( delete( $opts->{encoding} ) )
170             : undef;
171             my $disposition = defined( $opts->{disposition} )
172 110 100       397 ? lc( delete( $opts->{disposition} ) )
173             : undef;
174 110         318 my $filename = delete( $opts->{filename} );
175 110   100     984 my $cid = delete( $opts->{cid} ) || delete( $opts->{id} );
176 110         328 my $data = delete( $opts->{data} );
177 110         294 my $path = delete( $opts->{path} );
178 110         246 my $boundary = delete( $opts->{boundary} );
179 110         254 my $description = delete( $opts->{description} );
180 110 50       361 my $top = exists( $opts->{top} ) ? delete( $opts->{top} ) : 1;
181              
182 110 100 100     1991 if( !defined( $path ) &&
    100 100        
      66        
      33        
      66        
      33        
      66        
      66        
183             !defined( $data ) &&
184             exists( $opts->{attach} ) &&
185             defined( $opts->{attach} ) &&
186             ( !ref( $opts->{attach} ) || $self->_can_overload( $opts->{attach} => '""' ) ) &&
187             index( "$opts->{attach}", "\n" ) == -1 )
188             {
189 3         69 my $f = $self->new_file( delete( $opts->{attach} ) ); # Module::Generic::File will trigger stringification
190 3 100       433247 if( $f->exists )
191             {
192 2         246 $path = $f;
193             # Auto-detect the MIME type from the file content if not explicitly provided
194 2 50       18 if( !$has_type )
195             {
196 2         17 my $candidate = $f->finfo->mime_type;
197 2 50       98757 $type = "$candidate" if( $candidate );
198             }
199             }
200             }
201             # Auto-detect MIME type from path if not explicitly provided
202             elsif( !$has_type && defined( $path ) && CORE::length( $path ) )
203             {
204 8         149 my $f = $self->new_file( $path );
205 8         1345369 my $candidate = $f->finfo->mime_type;
206 8 50       123377 $type = "$candidate" if( $candidate );
207             }
208              
209 110         95502 my $is_multipart = ( $type =~ m{^multipart/}i );
210 110         1052 my $is_message = ( $type =~ m{^message/}i );
211              
212             # NOTE: 2. Validate the MIME type format
213 110   50     1392 my $ct_obj = Mail::Make::Headers::ContentType->new( $type ) ||
214             return( $self->pass_error( Mail::Make::Headers::ContentType->error ) );
215              
216             # NOTE: 3. Validate charset (text/* types only)
217 110 100       623 if( defined( $charset ) )
218             {
219 54 50       175 if( !$is_multipart )
220             {
221 54 50       293 $ct_obj->param( charset => $charset ) ||
222             return( $self->pass_error( $ct_obj->error ) );
223             }
224             else
225             {
226             # Silently discard charset on multipart - do not pass invalid param
227 0         0 undef( $charset );
228             }
229             }
230             else
231             {
232             # Default charset for text/* parts
233 56 100 66     564 if( $type =~ m{^text/}i && !$is_multipart )
234             {
235 9 50       60 $ct_obj->param( charset => 'utf-8' ) ||
236             return( $self->pass_error( $ct_obj->error ) );
237             }
238             }
239              
240             # NOTE: 4. Determine filename (from explicit param or basename of path)
241 110 100 100     50701 if( !defined( $filename ) && defined( $path ) && CORE::length( $path ) )
      66        
242             {
243 16         412 ( $filename ) = ( $path =~ m{([^/\\]+)\z} );
244             }
245             # Empty string -> treat as no filename
246 110 50 66     1123 undef( $filename ) if( defined( $filename ) && !CORE::length( $filename ) );
247              
248             # NOTE: 5. Content-Type set for filename
249             # 5. Set Content-Type name= parameter if we have a filename
250             # THIS IS THE KEY FIX: we go through ContentType->param() which
251             # handles RFC 2231 encoding, not a raw string that Mail::Field
252             # would misparse on commas.
253 110 100 66     645 if( defined( $filename ) && !$is_multipart )
254             {
255 26 50       178 $ct_obj->param( name => $filename ) ||
256             return( $self->pass_error( $ct_obj->error ) );
257             }
258              
259             # NOTE: 6. Validate / generate boundary for multipart
260 110 100       22793 if( $is_multipart )
261             {
262 24 100       116 if( defined( $boundary ) )
263             {
264 1 50       11 if( !CORE::length( $boundary ) )
    50          
265             {
266             # Empty boundary: warn and generate a fresh one
267 0         0 warn( "Empty boundary string provided; generating a new one\n" );
268 0         0 undef( $boundary );
269             }
270             elsif( $boundary =~ /[^0-9a-zA-Z'()+_,\-.\/:=? ]/ )
271             {
272 1         9 return( $self->error( "Boundary '$boundary' contains illegal characters" ) );
273             }
274             }
275 23   33     344 $boundary //= $self->make_boundary;
276             # Boundary is safe to pass directly (we just validated it)
277 23 50       178 $ct_obj->param( boundary => $boundary ) ||
278             return( $self->pass_error( $ct_obj->error ) );
279             }
280              
281             # NOTE: 7. Validate Content-Transfer-Encoding
282 109 100       19080 if( defined( $encoding ) )
283             {
284 54 100       451 if( $is_multipart )
    50          
    50          
285             {
286 1         8 return( $self->error(
287             "build(): encoding '$encoding' is not permitted for multipart type '$type'."
288             ) );
289             }
290             elsif( $is_message )
291             {
292             # RFC 2045 §6.4 - multipart and message types must not have a CTE
293 0         0 undef( $encoding );
294             }
295             elsif( $encoding eq 'suggest' )
296             {
297             # Deferred: computed after we know the body type
298 0         0 undef( $encoding );
299             }
300             else
301             {
302 53   100     897 my $cte_obj = Mail::Make::Headers::ContentTransferEncoding->new( $encoding ) ||
303             return( $self->pass_error( Mail::Make::Headers::ContentTransferEncoding->error ) );
304             # 'binary' is not valid for text/* parts in SMTP contexts
305 52 50 33     371 if( $cte_obj->is_binary && $type =~ m{^text/}i )
306             {
307 0         0 return( $self->error( "Encoding 'binary' is not permitted for text/* type '$type'" ) );
308             }
309             }
310             }
311              
312             # NOTE: 8. Build a fresh entity object with fresh headers
313 107 50       40377 my $entity = ref( $class ) ? $class->new : $class->new;
314 107 50       959 return( $self->pass_error ) if( !$entity );
315 107   50     917 my $headers = Mail::Make::Headers->new ||
316             return( $self->pass_error( Mail::Make::Headers->error ) );
317 107         1601 $entity->headers( $headers );
318              
319             # NOTE: 9. Attach body (single-part only)
320 107 100       416 if( !$is_multipart )
321             {
322 85 100 66     726 if( defined( $path ) && CORE::length( $path ) )
    100          
323             {
324 26   100     867 my $body = Mail::Make::Body::File->new( $path ) ||
325             return( $self->pass_error( Mail::Make::Body::File->error ) );
326 25         262207 $entity->body( $body );
327             }
328             elsif( defined( $data ) )
329             {
330 58   50     593 my $body = Mail::Make::Body::InCore->new( $data ) ||
331             return( $self->pass_error( Mail::Make::Body::InCore->error ) );
332 58         772 $entity->body( $body );
333             }
334             else
335             {
336 1         25 return( $self->error( "build: a body is required for non-multipart type '$type' - provide 'data' or 'path'" ) );
337             }
338             }
339              
340             # NOTE: 10. Auto-suggest encoding if not specified
341 105 100 100     1044 if( !$is_multipart && !defined( $encoding ) )
342             {
343 31         261 $entity->effective_type( $type );
344 31         345 $encoding = $entity->suggest_encoding;
345             }
346              
347             # NOTE: 11. Set the Content-Type header
348 105         946 $headers->replace( 'Content-Type' => $ct_obj->as_string );
349 105         1077 $entity->effective_type( $type );
350              
351             # NOTE: 12. Set Content-Transfer-Encoding header (single-part only)
352 105 50 66     955 if( !$is_multipart && defined( $encoding ) && CORE::length( $encoding ) )
      66        
353             {
354 83         337 $headers->replace( 'Content-Transfer-Encoding' => $encoding );
355             }
356              
357             # NOTE: 13. Set Content-Disposition header
358 105 100 100     1420 if( !$is_multipart && ( defined( $disposition ) || defined( $filename ) ) )
      100        
359             {
360             # Default to 'attachment' when we have a filename but no explicit disposition
361 26 50 66     167 $disposition //= ( defined( $filename ) ? 'attachment' : 'inline' );
362 26   100     480 my $cd_obj = Mail::Make::Headers::ContentDisposition->new( $disposition ) ||
363             return( $self->pass_error( Mail::Make::Headers::ContentDisposition->error ) );
364 25 50       113 if( defined( $filename ) )
365             {
366 25 50       297 $cd_obj->filename( $filename ) ||
367             return( $self->pass_error( $cd_obj->error ) );
368             }
369 25         168 $headers->replace( 'Content-Disposition' => $cd_obj->as_string );
370             }
371              
372             # NOTE: 14. Set Content-ID header (for inline parts)
373 104 100 66     150778 if( defined( $cid ) && CORE::length( $cid ) )
374             {
375             # Ensure it's wrapped in angle brackets
376 7 50       1748 $cid = "<${cid}>" unless( $cid =~ /\A<[^>]+>\z/ );
377 7         62 $headers->replace( 'Content-ID' => $cid );
378             }
379              
380             # NOTE: 15. Set Content-Description header
381 104 50 33     3890 if( defined( $description ) && CORE::length( $description ) )
382             {
383 0         0 $headers->replace( 'Content-Description' => $description );
384             }
385              
386 104         921 return( $entity );
387             }
388              
389             # effective_type( [$type_string] )
390             # Gets or sets the cached effective MIME type string.
391             sub effective_type
392             {
393 344     344 1 762 my $self = shift( @_ );
394 344 100       1107 if( @_ )
395             {
396 138         512 $self->{effective_type} = shift( @_ );
397 138         308 return( $self );
398             }
399             # Lazy: read from headers if not cached
400 206 50       849 unless( defined( $self->{effective_type} ) )
401             {
402             my $ct = $self->{headers}
403 0 0 0     0 ? ( $self->{headers}->content_type // '' )
404             : '';
405             # Strip parameters - just want type/subtype
406 0         0 ( $self->{effective_type} ) = ( $ct =~ m{^([^;\s]+)} );
407             }
408 206         1389 return( $self->{effective_type} );
409             }
410              
411             # encode_body()
412             # Encodes the body according to the Content-Transfer-Encoding header.
413             # No-op if already encoded or no encoding header is set.
414             sub encode_body
415             {
416 51     51 1 86 my $self = shift( @_ );
417 51 100       185 return( $self ) if( $self->{is_encoded} );
418 50   50     133 my $enc = lc( $self->headers->content_transfer_encoding // '' );
419 50 50       161 return( $self ) unless( CORE::length( $enc ) );
420 50         113 my $body = $self->{body};
421 50 50       190 unless( defined( $body ) )
422             {
423 0         0 return( $self->error( "encode_body: no body to encode." ) );
424             }
425              
426             # 7bit / 8bit: no transformation needed
427 50 50 66     488 if( $enc ne 'base64' && $enc ne 'quoted-printable' )
428             {
429 0         0 $self->{is_encoded} = 1;
430 0         0 return( $self );
431             }
432              
433             # Open a read handle on the source body
434 50   50     600 my $from_fh = $body->open || return( $self->pass_error( $body->error ) );
435              
436             # Choose the output destination to mirror the source:
437             # Body::File → encode to a temp file (no large attachment loaded into RAM)
438             # Body::InCore → encode into a scalar buffer in memory
439 50         127 my $new_body;
440 50 100       789 if( $body->is_on_file )
441             {
442 12   50     213 my $tmp = $self->new_tempfile( open => 1 ) || return( $self->pass_error );
443 12         1559553 $tmp->binmode( ':raw' );
444 12 50       18701 if( $enc eq 'base64' )
445             {
446 12         398 my $encoder = Mail::Make::Stream::Base64->new;
447 12 50       321 $encoder->encode( $from_fh => $tmp ) ||
448             return( $self->pass_error( $encoder->error ) );
449             }
450             else
451             {
452 0         0 my $encoder = Mail::Make::Stream::QuotedPrint->new;
453 0 0       0 $encoder->encode( $from_fh => $tmp ) ||
454             return( $self->pass_error( $encoder->error ) );
455             }
456 12   50     36557 $new_body = Mail::Make::Body::File->new( "$tmp" ) ||
457             return( $self->pass_error( Mail::Make::Body::File->error ) );
458             }
459             else
460             {
461 38         145 my $out = '';
462 38 50       111 if( $enc eq 'base64' )
463             {
464 0         0 my $encoder = Mail::Make::Stream::Base64->new;
465 0 0       0 $encoder->encode( $from_fh => \$out ) ||
466             return( $self->pass_error( $encoder->error ) );
467             }
468             else
469             {
470 38         811 my $encoder = Mail::Make::Stream::QuotedPrint->new;
471 38 50       833 $encoder->encode( $from_fh => \$out ) ||
472             return( $self->pass_error( $encoder->error ) );
473             }
474 38   50     85652 $new_body = Mail::Make::Body::InCore->new( $out ) ||
475             return( $self->pass_error( Mail::Make::Body::InCore->error ) );
476             }
477              
478 50         178594 $self->{body} = $new_body;
479 50         165 $self->{is_encoded} = 1;
480 50         421 return( $self );
481             }
482              
483             # epilogue( [$arrayref] )
484             sub epilogue
485             {
486 0     0 1 0 my $self = shift( @_ );
487 0 0       0 if( @_ )
488             {
489 0         0 my $val = shift( @_ );
490 0 0       0 $self->{epilogue} = ref( $val ) eq 'ARRAY' ? $val : [ $val ];
491 0         0 return( $self );
492             }
493 0         0 return( $self->{epilogue} );
494             }
495              
496             # headers( [$headers_object] )
497             sub headers
498             {
499 390     390 1 54058 my $self = shift( @_ );
500 390 100       1319 if( @_ )
501             {
502 108         211 my $h = shift( @_ );
503 108 50       691 if( !$self->_is_a( $h => 'Mail::Make::Headers' ) )
504             {
505 0         0 return( $self->error( "headers: argument must be a Mail::Make::Headers object" ) );
506             }
507 108         5396 $self->{headers} = $h;
508 108         285 return( $self );
509             }
510 282 50       954 unless( defined( $self->{headers} ) )
511             {
512 0   0     0 $self->{headers} = Mail::Make::Headers->new ||
513             return( $self->pass_error( Mail::Make::Headers->error ) );
514             }
515 282         1887 return( $self->{headers} );
516             }
517              
518             # is_encoded( [$bool] )
519             sub is_encoded
520             {
521 0     0 1 0 my $self = shift( @_ );
522 0 0       0 if( @_ )
523             {
524 0 0       0 $self->{is_encoded} = shift( @_ ) ? 1 : 0;
525 0         0 return( $self );
526             }
527 0         0 return( $self->{is_encoded} );
528             }
529              
530             # is_multipart()
531             # Returns true if the effective MIME type is multipart/*.
532             sub is_multipart
533             {
534 72     72 1 16480 my $self = shift( @_ );
535 72   50     276 my $type = $self->effective_type // '';
536 72 100       538 return( ( $type =~ m{^multipart/}i ) ? 1 : 0 );
537             }
538              
539             # is_binary()
540             # Returns true if the MIME type is not a text type.
541             sub is_binary
542             {
543 0     0 1 0 my $self = shift( @_ );
544 0 0       0 return( $self->textual_type( $self->effective_type ) ? 0 : 1 );
545             }
546              
547             # is_text()
548 0     0 1 0 sub is_text { return( !shift->is_binary ); }
549              
550             # length()
551             # Returns the exact serialised size in bytes of this entity (headers + CRLF separator +
552             # encoded body, recursively for multipart).
553             #
554             # The calculation mirrors print() exactly - no serialisation buffer is accumulated.
555             # For singlepart entities the body is encoded first (if not already done) and the
556             # encoded body's length is obtained via Body::File::length (stat on disk) or
557             # Body::InCore::length (in-memory scalar length) without loading the content into a
558             # fresh buffer.
559             # Headers are unavoidably stringified since they are always in memory.
560             sub length
561             {
562 0     0 1 0 my $self = shift( @_ );
563 0         0 my $total = 0;
564 7     7   59 use bytes;
  7         11  
  7         62  
565              
566             # Headers + the blank separator line
567 0         0 my $hdr_str = $self->headers->as_string;
568 0         0 $total += CORE::length( $hdr_str );
569 0         0 $total += CORE::length( $CRLF ); # blank line between headers and body
570              
571 0 0       0 if( $self->is_multipart )
    0          
572             {
573 0         0 my $boundary = $self->_extract_boundary;
574 0 0 0     0 unless( defined( $boundary ) && CORE::length( $boundary ) )
575             {
576 0         0 return( $self->error( "length: cannot measure multipart entity: no boundary." ) );
577             }
578              
579             # Preamble
580 0 0       0 if( @{$self->{preamble}} )
  0         0  
581             {
582 0         0 $total += CORE::length( join( $CRLF, @{$self->{preamble}} ) . $CRLF );
  0         0  
583             }
584              
585 0         0 for my $part ( @{$self->{_parts}} )
  0         0  
586             {
587 0         0 $total += CORE::length( "--${boundary}${CRLF}" );
588 0         0 my $part_len = $part->length;
589 0 0       0 return( $self->pass_error( $part->error ) ) unless( defined( $part_len ) );
590 0         0 $total += $part_len;
591 0         0 $total += CORE::length( $CRLF ); # post-part CRLF
592             }
593              
594             # Closing boundary
595 0         0 $total += CORE::length( "--${boundary}--${CRLF}" );
596              
597             # Epilogue
598 0 0       0 if( @{$self->{epilogue}} )
  0         0  
599             {
600 0         0 $total += CORE::length( join( $CRLF, @{$self->{epilogue}} ) . $CRLF );
  0         0  
601             }
602             }
603 0         0 elsif( @{$self->{_parts}} )
604             {
605             # Nested singlepart with sub-parts (e.g. message/rfc822)
606 0         0 my $need_sep = 0;
607 0         0 for my $part ( @{$self->{_parts}} )
  0         0  
608             {
609 0 0       0 $total += CORE::length( "${CRLF}${CRLF}" ) if( $need_sep++ );
610 0         0 my $part_len = $part->length;
611 0 0       0 return( $self->pass_error( $part->error ) ) unless( defined( $part_len ) );
612 0         0 $total += $part_len;
613             }
614             }
615             else
616             {
617             # Plain singlepart: encode if not already done, then ask the body for its byte
618             # length without reading it into a new buffer.
619 0 0       0 unless( defined( $self->{body} ) )
620             {
621 0         0 return( $self->error( "length: no body set on this entity." ) );
622             }
623 0 0       0 $self->encode_body || return( $self->pass_error );
624 0         0 my $body_len = $self->{body}->length;
625 0 0       0 return( $self->pass_error( $self->{body}->error ) ) unless( defined( $body_len ) );
626 0         0 $total += $body_len;
627             }
628              
629 0         0 return( $total );
630             }
631              
632             # make_boundary()
633             # Generates a unique boundary string suitable for MIME use.
634 24     24 1 4890 sub make_boundary { return( Data::UUID->new->create_str ); }
635              
636             # mime_type()
637             # Returns just the type/subtype portion (no parameters).
638             sub mime_type
639             {
640 25     25 1 32095 my $self = shift( @_ );
641 25   50     93 my $type = $self->effective_type // '';
642 25         189 ( my $bare ) = ( $type =~ m{^([^;\s]+)} );
643 25         209 return( $bare );
644             }
645              
646             # parts( [$arrayref_or_list] )
647             # Gets or sets the list of sub-parts.
648             sub parts
649             {
650 11     11 1 67 my $self = shift( @_ );
651 11 50       44 if( @_ )
652             {
653 0 0       0 my $parts = ref( $_[0] ) eq 'ARRAY' ? $_[0] : [ @_ ];
654 0         0 for my $part ( @$parts )
655             {
656 0 0       0 unless( $self->_is_a( $part => 'Mail::Make::Entity' ) )
657             {
658 0         0 return( $self->error( "parts: each element must be a Mail::Make::Entity" ) );
659             }
660             }
661 0         0 $self->{_parts} = $parts;
662 0         0 return( $self );
663             }
664 11 50       91 return( wantarray() ? @{$self->{_parts}} : $self->{_parts} );
  0         0  
665             }
666              
667             # preamble( [$arrayref] )
668             sub preamble
669             {
670 0     0 1 0 my $self = shift( @_ );
671 0 0       0 if( @_ )
672             {
673 0         0 my $val = shift( @_ );
674 0 0       0 $self->{preamble} = ref( $val ) eq 'ARRAY' ? $val : [ $val ];
675 0         0 return( $self );
676             }
677 0         0 return( $self->{preamble} );
678             }
679              
680             # print( [$fh] )
681             # Serialises the entity to a filehandle.
682             sub print
683             {
684 64     64 1 164 my $self = shift( @_ );
685 64   50     196 my $fh = shift( @_ ) ||
686             return( $self->error( "No file handle was provided to print the mail entity to." ) );
687 64 50       419 unless( $self->_is_glob( $fh ) )
688             {
689 0   0     0 return( $self->error( "Value provided (", $self->_str_val( $fh // 'undef' ), ") is not a file handle." ) );
690             }
691             # Headers
692 64 50       1042 print( $fh $self->headers->as_string ) ||
693             return( $self->error( "Cannot write headers: $!" ) );
694             # Blank line separating headers from body
695 64 50       263 print( $fh $CRLF ) ||
696             return( $self->error( "Cannot write header/body separator: $!" ) );
697             # Body
698 64 50       438 $self->print_body( $fh ) || return( $self->pass_error );
699 64         291 return( $self );
700             }
701              
702             # print_body( [$fh] )
703             # Serialises the body (or multipart boundaries and sub-parts) to a filehandle.
704             sub print_body
705             {
706 64     64 1 130 my $self = shift( @_ );
707 64   50     176 my $fh = shift( @_ ) ||
708             return( $self->error( "No file handle was provided to print the mail entity to." ) );
709 64 50       289 unless( $self->_is_glob( $fh ) )
710             {
711 0   0     0 return( $self->error( "Value provided (", $self->_str_val( $fh // 'undef' ), ") is not a file handle." ) );
712             }
713 64 100       1036 if( $self->is_multipart )
    50          
714             {
715 13         99 my $boundary = $self->_extract_boundary;
716 13 50 33     103 unless( defined( $boundary ) && CORE::length( $boundary ) )
717             {
718 0         0 return( $self->error( "Cannot serialise multipart entity: no boundary in Content-Type" ) );
719             }
720              
721             # Preamble
722 13 50       24 if( @{$self->{preamble}} )
  13         54  
723             {
724 0         0 print( $fh join( $CRLF, @{$self->{preamble}} ) . $CRLF );
  0         0  
725             }
726 13         19 foreach my $part ( @{$self->{_parts}} )
  13         50  
727             {
728 27 50       327 print( $fh "--${boundary}${CRLF}" ) ||
729             return( $self->error( "Cannot write part boundary: $!" ) );
730 27 50       151 $part->print( $fh ) ||
731             return( $self->pass_error( $part->error ) );
732 27 50       168 print( $fh $CRLF ) ||
733             return( $self->error( "Cannot write post-part CRLF: $!" ) );
734             }
735 13 50       116 print( $fh "--${boundary}--${CRLF}" ) ||
736             return( $self->error( "Cannot write closing boundary: $!" ) );
737             # Epilogue
738 13 50       22 if( @{$self->{epilogue}} )
  13         62  
739             {
740 0         0 print( $fh join( $CRLF, @{$self->{epilogue}} ) . $CRLF );
  0         0  
741             }
742             }
743 51         237 elsif( @{$self->{_parts}} )
744             {
745             # Nested singlepart with sub-parts (e.g. message/rfc822)
746 0         0 my $need_sep = 0;
747 0         0 foreach my $part ( @{$self->{_parts}} )
  0         0  
748             {
749 0 0       0 print( $fh "${CRLF}${CRLF}" ) if( $need_sep++ );
750 0 0       0 $part->print( $fh ) || return( $self->pass_error( $part->error ) );
751             }
752             }
753             else
754             {
755             # Plain single part
756 51 50       167 unless( defined( $self->{body} ) )
757             {
758 0         0 return( $self->error( "print_body: no body set on this entity" ) );
759             }
760 51 50       293 $self->encode_body || return( $self->pass_error );
761             my $in_ref = $self->{body}->as_string ||
762 51   50     167809 return( $self->pass_error( $self->{body}->error ) );
763 51 50       453 print( $fh $$in_ref ) ||
764             return( $self->error( "Cannot write body: $!" ) );
765             }
766 64         307 return( $self );
767             }
768              
769             # stringify()
770             # Alias for as_string.
771 0     0 1 0 sub stringify { return( shift->as_string ); }
772              
773 0     0 1 0 sub stringify_ref { return( shift->as_string_ref ); }
774              
775             # stringify_body()
776             # Alias for body_as_string.
777 0     0 1 0 sub stringify_body { return( shift->body_as_string ); }
778              
779             # stringify_header()
780 0     0 1 0 sub stringify_header { return( shift->headers->as_string ); }
781              
782             # suggest_encoding()
783             # Returns a suitable Content-Transfer-Encoding for this entity's MIME type.
784             # Rules:
785             # multipart/* / message/* -> '' (no encoding)
786             # text/* -> quoted-printable
787             # everything else -> base64
788             sub suggest_encoding
789             {
790 31     31 1 85 my $self = shift( @_ );
791 31   50     102 my $type = $self->effective_type // '';
792             # Strip any parameters
793 31         372 ( $type ) = ( $type =~ m{^([^;\s]+)} );
794 31         134 $type = lc( $type );
795 31 50       223 return( '' ) if( $type =~ m{^multipart/} );
796 31 50       176 return( '' ) if( $type =~ m{^message/} );
797 31 100       168 return( 'quoted-printable' ) if( $type =~ m{^text/} );
798 23         150 return( 'base64' );
799             }
800              
801             # textual_type( $mime_type )
802             # Returns true if the given MIME type is a text or message type.
803             sub textual_type
804             {
805 0     0 1 0 my( $self, $type ) = @_;
806 0 0 0     0 return(0) unless( defined( $type ) && CORE::length( $type ) );
807 0 0       0 return( ( $type =~ m{^(text|message)(/|\z)}i ) ? 1 : 0 );
808             }
809              
810             # make_multipart( [ $subtype [, %opts] ] )
811             # Promotes a single-part entity to multipart by wrapping the existing body in a child
812             # entity and replacing the Content-Type with multipart/$subtype.
813             sub make_multipart
814             {
815 2     2 1 8428 my $self = shift( @_ );
816 2   50     20 my $subtype = shift( @_ ) // 'mixed';
817 2         11 $subtype = lc( $subtype );
818 2 100       13 return( $self ) if( $self->is_multipart );
819 1         4 my $new_type = "multipart/${subtype}";
820 1         6 my $boundary = $self->make_boundary;
821 1         7 my $h = $self->headers;
822 1 50       7 if( $h )
823             {
824 1   50     8 my $ct_obj = Mail::Make::Headers::ContentType->new( $new_type ) ||
825             return( $self->pass_error( Mail::Make::Headers::ContentType->error ) );
826 1 50       7 $ct_obj->boundary( $boundary ) || return( $self->pass_error( $ct_obj->error ) );
827 1 50       536 $h->set( 'Content-Type', "$ct_obj" ) || return( $self->pass_error( $h->error ) );
828 1         7 $h->remove( 'Content-Transfer-Encoding' );
829             }
830 1 50       7542 if( $self->body )
831             {
832 1         8 my $old_type = $self->effective_type;
833 1   50     11 my $child = Mail::Make::Entity->new ||
834             return( $self->pass_error );
835 1   50     15 my $child_h = Mail::Make::Headers->new ||
836             return( $self->pass_error( Mail::Make::Headers->error ) );
837 1 50       16 my $orig_ct = $h ? $h->get( 'Content-Type' ) : undef;
838 1 50       11 $child_h->set( 'Content-Type', $orig_ct ) if( defined( $orig_ct ) );
839 1         8 $child->headers( $child_h );
840 1         5 $child->body( $self->body );
841 1         6 $child->effective_type( $old_type );
842 1         3 $self->{body} = undef;
843 1         2 push( @{$self->{_parts}}, $child );
  1         6  
844             }
845 1         4 $self->effective_type( $new_type );
846 1         5 return( $self );
847             }
848              
849             # purge()
850             # Recursively releases body content.
851             sub purge
852             {
853 1     1 1 2 my $self = shift( @_ );
854 1 50       4 if( my $body = $self->body )
855             {
856 1         14 $body->purge;
857 1         2 $self->{body} = undef;
858             }
859 1         3 for my $part ( @{$self->{_parts}} )
  1         3  
860             {
861 0         0 $part->purge;
862             }
863 1         4 return( $self );
864             }
865              
866             # _extract_boundary()
867             # Pulls the boundary parameter out of the Content-Type header string.
868             sub _extract_boundary
869             {
870 13     13   26 my $self = shift( @_ );
871             # content_type() returns a typed object; we need the raw string.
872 13   50     39 my $ct = $self->headers->get( 'Content-Type' ) // '';
873 13 50       52 return( undef ) unless( CORE::length( $ct ) );
874             # Match boundary="..." or boundary=...
875 13 50       209 if( $ct =~ /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i )
876             {
877 13   66     125 return( $1 // $2 );
878             }
879 0           return( undef );
880             }
881              
882             # NOTE: STORABLE support
883 0     0 0   sub STORABLE_freeze { CORE::return( CORE::shift->FREEZE( @_ ) ); }
884              
885 0     0 0   sub STORABLE_thaw { CORE::return( CORE::shift->THAW( @_ ) ); }
886              
887             1;
888             # NOTE: POD
889             __END__
890              
891             =encoding utf-8
892              
893             =head1 NAME
894              
895             Mail::Make::Entity - MIME Part Builder for Mail::Make
896              
897             =head1 SYNOPSIS
898              
899             use Mail::Make::Entity;
900              
901             # Build a text/plain part
902             my $part = Mail::Make::Entity->build(
903             type => 'text/plain',
904             charset => 'utf-8',
905             data => "Hello, World!\n",
906             ) || die( Mail::Make::Entity->error );
907              
908             # Build a multipart/related container
909             my $container = Mail::Make::Entity->build(
910             type => 'multipart/related',
911             ) || die( Mail::Make::Entity->error );
912              
913             # Add an inline image with a comma in the filename
914             my $img = Mail::Make::Entity->build(
915             type => 'image/png',
916             path => '/var/www/images/Yamato,Inc-Logo.png',
917             disposition => 'inline',
918             cid => 'logo@yamato-inc',
919             ) || die( Mail::Make::Entity->error );
920             $container->add_part( $img );
921              
922             print $container->as_string;
923              
924             =head1 VERSION
925              
926             v0.4.1
927              
928             =head1 DESCRIPTION
929              
930             The core MIME part object for L<Mail::Make>. Represents a single MIME entity (either a leaf part with a body, or a multipart container with sub-parts).
931              
932             The C<build()> class method is the primary factory. It performs strict input validation, automatic RFC 2231 encoding of filenames with special characters, and deterministic Content-Transfer-Encoding selection. It never silently corrupts a message - all invalid inputs produce an explicit error.
933              
934             =head1 CLASS METHOD
935              
936             =head2 build( %params )
937              
938             Builds and returns a new C<Mail::Make::Entity>. Parameters:
939              
940             =over 4
941              
942             =item attach
943              
944             A positional shorthand for C<path>. Accepts a plain scalar or a stringifiable object. If the value resolves to an existing file on disk, C<path>, and C<filename> are set automatically. C<type> is derived from the file itself, using L<Module::Generic::File::Magic>, as well if not already provided. Ignored if C<path> or C<data> is already provided.
945              
946             # Shorthand
947             Mail::Make::Entity->build( attach => 'report.pdf' );
948              
949             # Equivalent explicit form
950             Mail::Make::Entity->build( path => 'report.pdf' );
951              
952             =item boundary
953              
954             Boundary string for C<multipart/*> types. Validated against RFC 2046 allowed characters. Auto-generated if omitted.
955              
956             =item charset
957              
958             Charset for C<text/*> parts. Validated via L<Encode>. Default: C<utf-8> for text/* parts if not specified.
959              
960             =item cid
961              
962             Content-ID for inline parts (e.g. embedded images). Angle brackets are added automatically if missing.
963              
964             =item data
965              
966             Scalar body content (for in-memory bodies).
967              
968             =item description
969              
970             Optional C<Content-Description> value.
971              
972             =item disposition
973              
974             C<inline> or C<attachment>. Defaults to C<attachment> when a filename is present.
975              
976             =item encoding
977              
978             Content-Transfer-Encoding. One of C<7bit>, C<8bit>, C<binary>, C<base64>, C<quoted-printable>. Auto-suggested if omitted. C<binary> is rejected for C<text/*> types.
979              
980             =item filename
981              
982             Filename for C<Content-Type: name=> and C<Content-Disposition: filename=>. Values containing commas or other RFC 2045 specials are automatically RFC 2231 encoded. If not provided and C<path> is given, the basename is used.
983              
984             =item path
985              
986             File path (for on-disk bodies). The file must exist and be readable.
987              
988             =item type
989              
990             MIME C<type/subtype> string. Default: C<text/plain>.
991              
992             =back
993              
994             Returns C<undef> and sets an error on failure.
995              
996             =head1 METHODS
997              
998             =head2 add_part( $entity )
999              
1000             Appends a C<Mail::Make::Entity> as a sub-part of this entity.
1001              
1002             =head2 as_string
1003              
1004             Returns the serialised entity (headers + blank line + encoded body) as a plain string. This is the form expected by C<print>, string interpolation, and most downstream consumers.
1005              
1006             For large messages where avoiding a string copy matters, use L</as_string_ref> instead.
1007              
1008             =head2 as_string_ref
1009              
1010             Returns the serialised entity as a B<scalar reference>. No string copy is made: the same buffer used during serialisation is returned directly. Dereference with C<$$ref> when a plain string is needed.
1011              
1012             =head2 body( [$body] )
1013              
1014             Gets or sets the L<Mail::Make::Body>-derived body object.
1015              
1016             =head2 body_as_string
1017              
1018             Returns a scalar reference to the (encoded) body content.
1019              
1020             =head2 effective_type( [$type] )
1021              
1022             Gets or sets the cached MIME C<type/subtype> string.
1023              
1024             =head2 encode_body
1025              
1026             Encodes the body according to the C<Content-Transfer-Encoding> header. No-op if already encoded.
1027              
1028             =head2 epilogue( [$arrayref] )
1029              
1030             Gets or sets the epilogue lines (appended after the closing boundary).
1031              
1032             =head2 headers( [$headers] )
1033              
1034             Gets or sets the L<Mail::Make::Headers> collection.
1035              
1036             =head2 is_binary
1037              
1038             Returns true if the effective MIME type is not a text type.
1039              
1040             =head2 is_encoded( [$bool] )
1041              
1042             Gets or sets the encoded flag.
1043              
1044             =head2 is_multipart
1045              
1046             Returns true if the effective MIME type is C<multipart/*>.
1047              
1048             =head2 is_text
1049              
1050             Returns true if the effective MIME type is a text type.
1051              
1052             =head2 length
1053              
1054             my $bytes = $entity->length;
1055              
1056             Returns the exact serialised size in bytes of this entity: headers, the blank CRLF separator, and the encoded body (recursively for multipart entities, including all boundary lines, preamble, and epilogue).
1057              
1058             The calculation mirrors L</print> exactly without accumulating a serialisation buffer. For singlepart entities the body is encoded first via L</encode_body> (if not already done), then L<Mail::Make::Body::File/length> (a C<stat> call) or L<Mail::Make::Body::InCore/length> (a scalar byte count) is used to obtain the encoded body size - the content is never loaded into a second buffer.
1059             Headers are stringified since they are always held in memory.
1060              
1061             Returns C<undef> and sets C<error()> on failure.
1062              
1063             =head2 make_boundary
1064              
1065             Generates a unique boundary string.
1066              
1067             =head2 mime_type
1068              
1069             Returns the bare MIME type (without parameters).
1070              
1071             =head2 parts( [$arrayref | @list] )
1072              
1073             Gets or sets the list of sub-part entities.
1074              
1075             =head2 preamble( [$arrayref] )
1076              
1077             Gets or sets the preamble lines (before the first boundary).
1078              
1079             =head2 print( [$fh] )
1080              
1081             Serialises the entity to a filehandle.
1082              
1083             =head2 print_body( [$fh] )
1084              
1085             Serialises only the body portion to a filehandle.
1086              
1087             =head2 stringify
1088              
1089             Alias for L</as_string>.
1090              
1091             =head2 stringify_ref
1092              
1093             Alias for L</as_string_ref>.
1094              
1095             =head2 stringify_body
1096              
1097             Alias for L</body_as_string>.
1098              
1099             =head2 stringify_header
1100              
1101             Returns the header block as a string.
1102              
1103             =head2 suggest_encoding
1104              
1105             Returns the recommended Content-Transfer-Encoding for this entity's MIME type.
1106              
1107             =head2 make_multipart( [$subtype] )
1108              
1109             $entity->make_multipart( 'mixed' );
1110             $entity->make_multipart( 'alternative' );
1111              
1112             Promotes a single-part entity into a C<multipart/$subtype> container in-place.
1113             The default subtype is C<mixed> if none is supplied.
1114              
1115             If the entity is already multipart, the method returns C<$self> immediately without making any changes.
1116              
1117             The existing body (if any) is wrapped into a child entity that preserves the original C<Content-Type>, and that child becomes the first part of the new container. A fresh MIME boundary is generated via L</make_boundary>.
1118              
1119             The outer container's C<Content-Transfer-Encoding> header is removed, as transfer encoding applies to individual parts rather than the container itself.
1120              
1121             Returns C<$self> on success, C<undef> on error.
1122              
1123             =head2 purge
1124              
1125             $entity->purge;
1126              
1127             Recursively releases all body content held by this entity and its child parts.
1128              
1129             For each node in the entity tree, the body object's own C<purge()> method is called (which may, for example, delete a temporary file backing a L<Mail::Make::Body::File>), then the reference is cleared.
1130              
1131             Returns C<$self>.
1132              
1133             =head2 textual_type( $mime_type )
1134              
1135             Returns true if the given MIME type is C<text/*> or C<message/*>.
1136              
1137             =head1 AUTHOR
1138              
1139             Jacques Deguest E<lt>F<jack@deguest.jp>E<gt>
1140              
1141             =head1 SEE ALSO
1142              
1143             RFC 2045, RFC 2046, RFC 2047, RFC 2231
1144              
1145             L<Mail::Make>, L<Mail::Make::Headers>, L<Mail::Make::Body>, L<Mail::Make::Stream::Base64>, L<Mail::Make::Stream::QuotedPrint>
1146              
1147             =head1 COPYRIGHT & LICENSE
1148              
1149             Copyright(c) 2026 DEGUEST Pte. Ltd.
1150              
1151             All rights reserved.
1152              
1153             This program is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
1154              
1155             =cut