Hello,
I am trying to send an email programmatically from SAS Studio using the Microsoft Graph API. I am able to send the email successfully along with an attachment.
However, the main issue is that the attachment is not in a proper format. For PDF, Excel, and ZIP files, I receive an “invalid format” error. Below is the code I used to send a ZIP file via email. Although I do receive the ZIP file, when I try to open it, I get the error: “Windows cannot open the folder. The compressed (zipped) file is invalid.”
Could you please advise whether there is an issue in the code or if Microsoft Graph cannot be used to send attachments in the correct format?
filename resp temp;
proc http
url="https://login.microsoftonline.com/<tenant_id>/oauth2/v2.0/token"
in='grant_type=client_credentials&client_id=<client_id>&client_secret=<client_secret>&scope=https://graph.microsoft.com/.default'
ct="application/x-www-form-urlencoded"
out=resp
method='POST';
run;
libname auth json fileref=resp;
data _null_;
set auth.root;
call symputx('access_token',access_token);
run;
/*--------------------------------*/
/* Encode attachment */
/*--------------------------------*/
filename src "/mnt/discovery/ukida/_shared/test.zip" recfm=n;
filename tgt "/tmp/test.zip" recfm=n;
data _null_;
infile src recfm=n lrecl=32767;
file tgt recfm=n lrecl=32767;
length buffer $32767;
input buffer $char32767.;
put buffer $char32767. @;
run;
filename raw "/tmp/test.zip" recfm=n;
filename enc temp;
data _null_;
infile raw recfm=n end=eof;
file enc lrecl=76;
length buffer $57;
retain buffer '';
input byte $char1.;
buffer = cats(buffer, byte);
if length(buffer) = 57 then do;
put buffer $base64X76.;
buffer = '';
end;
if eof and length(buffer) > 0 then do;
put buffer $base64X76.;
end;
run;
/*--------------------------------*/
/* Load into macro variable */
/*--------------------------------*/
data _null_;
infile enc truncover;
length file_b64 $32767;
retain file_b64 '';
input line $char32767.;
line = compress(line,,'kw');
file_b64 = cats(file_b64, line);
call symputx('file_b64', file_b64, 'G');
run;
%put >>> BASE64 LENGTH = %length(&file_b64);
filename mail_in temp;
data _null_;
file mail_in lrecl=32767;
put '{';
put ' "message": {';
put ' "subject": "Test SAS Viya email with attachment",';
put ' "body": {';
put ' "contentType": "Text",';
put ' "content": "Hello! This email has an attachment."';
put ' },';
put ' "toRecipients": [';
put ' { "emailAddress": { "address": "[email protected]" } }';
put ' ],';
put ' "attachments": [';
put ' {';
put ' "@odata.type": "#microsoft.graph.fileAttachment",';
put ' "name": "test.zip",';
put ' "contentType": "application/zip",';
put " ""contentBytes"": ""&file_b64""";
put ' }';
put ' ]';
put ' },';
put ' "saveToSentItems": true';
put '}';
run;
filename sendresp temp;
proc http
url="https://graph.microsoft.com/v1.0/users/[email protected]/sendMail"
method="POST"
in=mail_in
out=sendresp;
headers
"Authorization"="Bearer &access_token"
"Content-Type"="application/json";
run;
data _null_;
infile sendresp;
input;
put _infile_;
run;
Sounds like the problem is with your code to generate the base64 content, so you are ending up with a bad attachment file.
If you replace your code from "filename src..." to "filename sendresp temp;" with this, does it work? Here I'm building the JSON structure around the base64 encoded file using put and then processing the attachment in 15,000 byte chunks into the contentBytes field, then at the end closing the JSON structure.
/* Define the source file and the temporary file for the email request payload. */
filename src "/mnt/discovery/ukida/_shared/test.zip" recfm=f lrecl=15000;
filename mailin temp;
data _null_;
file mailin recfm=n; /* Write the file as a byte stream without any record formatting */
infile src length=bytes_read truncover end=eof;
/* Initialize variables */
length
chunk $ 15000
fmt_string $ 20
b64_chunk $ 20000
;
/* Build the JSON structure for the payload */
if _n_ = 1 then do;
put '{' /
' "message": {' /
' "subject": "Test SAS Viya email with attachment",' /
' "body": {' /
' "contentType": "Text",' /
' "content": "Hello! This email has an attachment." ' /
' },' /
' "toRecipients": [' /
' { "emailAddress": { "address": "[email protected]" } }' /
' ],' /
' "attachments": [' /
' {' /
' "@odata.type": "#microsoft.graph.fileAttachment",' /
' "name": "test.zip",' /
' "contentType": "application/zip",' /
' "contentBytes": "'; /* Leave this open */
end;
/* Process the attachment in 15000 byte chunks, encoding each chunk to base64 */
input chunk $char15000.;
b64_len = ceil(bytes_read / 3) * 4;
fmt_string = compress("$base64x" || put(b64_len, best.));
b64_chunk = strip(putc(substr(chunk, 1, bytes_read), fmt_string));
actual_b64_len = length(b64_chunk);
/* Write the base64 block directly into the open contentBytes quotes */
put b64_chunk $varying20000. actual_b64_len @@;
/* Close the contentBytes quotes and JSON structure when the end of the file is reached. */
if eof then do;
put '"' /
' }' /
' ]' /
' },' /
' "saveToSentItems": "true"' /
'}';
end;
run;
Sounds like the problem is with your code to generate the base64 content, so you are ending up with a bad attachment file.
If you replace your code from "filename src..." to "filename sendresp temp;" with this, does it work? Here I'm building the JSON structure around the base64 encoded file using put and then processing the attachment in 15,000 byte chunks into the contentBytes field, then at the end closing the JSON structure.
/* Define the source file and the temporary file for the email request payload. */
filename src "/mnt/discovery/ukida/_shared/test.zip" recfm=f lrecl=15000;
filename mailin temp;
data _null_;
file mailin recfm=n; /* Write the file as a byte stream without any record formatting */
infile src length=bytes_read truncover end=eof;
/* Initialize variables */
length
chunk $ 15000
fmt_string $ 20
b64_chunk $ 20000
;
/* Build the JSON structure for the payload */
if _n_ = 1 then do;
put '{' /
' "message": {' /
' "subject": "Test SAS Viya email with attachment",' /
' "body": {' /
' "contentType": "Text",' /
' "content": "Hello! This email has an attachment." ' /
' },' /
' "toRecipients": [' /
' { "emailAddress": { "address": "[email protected]" } }' /
' ],' /
' "attachments": [' /
' {' /
' "@odata.type": "#microsoft.graph.fileAttachment",' /
' "name": "test.zip",' /
' "contentType": "application/zip",' /
' "contentBytes": "'; /* Leave this open */
end;
/* Process the attachment in 15000 byte chunks, encoding each chunk to base64 */
input chunk $char15000.;
b64_len = ceil(bytes_read / 3) * 4;
fmt_string = compress("$base64x" || put(b64_len, best.));
b64_chunk = strip(putc(substr(chunk, 1, bytes_read), fmt_string));
actual_b64_len = length(b64_chunk);
/* Write the base64 block directly into the open contentBytes quotes */
put b64_chunk $varying20000. actual_b64_len @@;
/* Close the contentBytes quotes and JSON structure when the end of the file is reached. */
if eof then do;
put '"' /
' }' /
' ]' /
' },' /
' "saveToSentItems": "true"' /
'}';
end;
run;
Thank you so much @gwootton . It worked.
I am currently trying to create a single macro that can handle attachments of all file formats in an email. The idea is to be able to call the macro with the required parameters using the code below.
I have tried my best to get this working, but I have not been able to complete it successfully. It consistently throws the following error:
%macro send_mail(
to=,
cc=,
subject=,
body=,
attachment=
);
%local access_token has_cc has_attach content_type attachment_name;
%let has_cc = %sysfunc(ifc(%length(&cc) > 0, 1, 0));
%let has_attach = %sysfunc(ifc(%length(&attachment) > 0, 1, 0));
%let SENDER_EMAIL = [email protected];
/*--------------------------------*/
/* Read secrets */
/*--------------------------------*/
%macro read_secret(file,macvar);
data _null_;
infile "&file" lrecl=32767 truncover;
input _value_ $char32767.;
call symputx("&macvar", strip(_value_), 'G');
run;
%mend;
%read_secret(/secrets/sasmail_server_client_id,CLIENT_ID);
%read_secret(/secrets/sasmail_server_client_secret,CLIENT_SECRET);
%read_secret(/secrets/entra_id_tenant_id,TENANT_ID);
/*--------------------------------*/
/* Get OAuth token */
/*--------------------------------*/
filename resp temp;
proc http
url="https://login.microsoftonline.com/&TENANT_ID/oauth2/v2.0/token"
method="POST"
in="grant_type=client_credentials%str(&)
client_id=&CLIENT_ID%str(&)
client_secret=&CLIENT_SECRET%str(&)
scope=https://graph.microsoft.com/.default"
ct="application/x-www-form-urlencoded"
out=resp;
run;
libname auth json fileref=resp;
data _null_;
set auth.root;
call symputx('access_token', access_token);
run;
%if &has_attach %then %do;
data _null_;
length fname $256 ext $10 ctype $100;
fname = "&attachment";
attachment_name = scan(fname, -1, '/\');
ext = lowcase(scan(fname, -1, '.'));
select (ext);
when ('csv') ctype='text/csv';
when ('txt') ctype='text/plain';
when ('zip') ctype='application/zip';
when ('doc') ctype='application/msword';
when ('docx') ctype='application/vnd.openxmlformats-officedocument.wordprocessingml.document';
when ('pdf') ctype='application/pdf';
when ('xls') ctype='application/vnd.ms-excel';
when ('xlsx') ctype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
when ('ppt') ctype='application/vnd.ms-powerpoint';
when ('pptx') ctype='application/vnd.openxmlformats-officedocument.presentationml.presentation';
when ('html','htm') ctype='text/html';
otherwise ctype='application/octet-stream';
end;
call symputx('content_type', ctype);
call symputx('attachment_name', attachment_name);
run;
%end;
/*--------------------------------*/
/* Build JSON with attachment */
/*--------------------------------*/
filename mail temp;
%if &has_attach %then %do;
filename src "&attachment" recfm=f lrecl=15000;
data _null_;
file mail recfm=n;
length chunk $15000 b64_chunk $20000 fmt_string $20;
if _n_ = 1 then do;
subject_txt = tranwrd(symget('subject'), '"', '\"');
body_txt = tranwrd(symget('body'), '"', '\"');
put '{'
' "message": {'
' "subject": "' subject_txt '",'
' "body": {'
' "contentType": "HTML",'
' "content": "' body_txt '"'
' },'
' "toRecipients": [';
%do i=1 %to %sysfunc(countw(&to));
%if &i>1 %then put ",";
put ' { "emailAddress": { "address": "%scan(&to,&i)" } }';
%end;
put ']';
%if &has_cc %then %do;
put ',"ccRecipients": [';
%do j=1 %to %sysfunc(countw(&cc));
%if &j>1 %then put ",";
put ' { "emailAddress": { "address": "%scan(&cc,&j)" } }';
%end;
put ']';
%end;
put ',"attachments":[{'
'"@odata.type":"#microsoft.graph.fileAttachment",'
'"name":"' "&attachment_name" '",'
'"contentType":"' "&content_type" '",'
'"contentBytes":"';
end;
infile src recfm=f lrecl=15000 end=eof length=bytes_read;
input chunk $char15000.;
b64_len = ceil(bytes_read/3)*4;
fmt_string = cats('$base64x', b64_len);
b64_chunk = putc(substr(chunk,1,bytes_read), fmt_string);
put b64_chunk $varying20000. length(b64_chunk) @@;
if eof then do;
put '"}]},"saveToSentItems":"true"}';
end;
run;
%end;
%else %do;
data _null_;
file mail lrecl=32767;
subject_txt = tranwrd(symget('subject'), '"', '\"');
body_txt = tranwrd(symget('body'), '"', '\"');
put '{';
put ' "message": {';
put ' "subject": "' subject_txt '",';
put ' "body": {';
put ' "contentType": "HTML",';
put ' "content": "' body_txt '"';
put ' },';
put ' "toRecipients": [';
%do i=1 %to %sysfunc(countw(&to));
%if &i>1 %then put ",";
put ' { "emailAddress": { "address": "%scan(&to,&i)" } }';
%end;
put ']';
%if &has_cc %then %do;
put ',"ccRecipients": [';
%do j=1 %to %sysfunc(countw(&cc));
%if &j>1 %then put ",";
put ' { "emailAddress": { "address": "%scan(&cc,&j)" } }';
%end;
put ']';
%end;
put ' },';
put ' "saveToSentItems": true';
put '}';
run;
%end;
/*--------------------------------*/
/* Send email */
/*--------------------------------*/
filename sendresp temp;
proc http
url="https://graph.microsoft.com/v1.0/users/&SENDER_EMAIL/sendMail"
method="POST"
in=mail
out=sendresp;
headers
"Authorization"="Bearer &access_token"
"Content-Type"="application/json";
run;
data _null_;
infile sendresp;
input;
putlog _infile_;
run;
%mend send_mail;
data null;
format dte ddmmyy10. tme time8.;
dt = datetime();
call symputx(
'mail_body',
catx(
'',
'<html><body>',
'<p>Hello,</p>',
'<p>The scheduled job completed successfully on ',
put(datepart(dt), ddmmyy10.),
' at ',
put(timepart(dt), time8.),
'.</p>',
'<p>Thank you,<br>',
'Support Team</p>',
'</body></html>'
),
'L'
);
run;
%send_mail(
[email protected] [email protected],
subject=Test SAS Extract Completed Successfully,
body=%superq(mail_body)
attachment=/mnt/discovery/ukida/_shared/test14.zip
);
I have finally made this work. Thanks @gwootton for your help.
%macro send_mail(
to=,
cc=,
subject=,
body=,
attachment=
);
%local access_token has_cc has_attach content_type attachment_name
n_to n_cc i j;
%let has_cc = %sysfunc(ifc(%length(&cc) > 0, 1, 0));
%let has_attach = %sysfunc(ifc(%length(&attachment) > 0, 1, 0));
%let SENDER_EMAIL = [email protected];
/* ── Read secrets ─────────────────────────────────────────── */
%macro read_secret(file, macvar);
data _null_;
infile "&file" lrecl=32767 truncover;
input _value_ $char32767.;
call symputx("&macvar", strip(_value_), 'G');
run;
%mend;
%read_secret(/var/secrets/sasmail_server_client_id, CLIENT_ID)
%read_secret(/var/secrets/sasmail_server_client_secret, CLIENT_SECRET)
%read_secret(/var/secrets/entra_id_tenant_id, TENANT_ID)
/* ── OAuth token ──────────────────────────────────────────── */
filename tkn temp;
proc http
url = "https://login.microsoftonline.com/&TENANT_ID/oauth2/v2.0/token"
method = "POST"
in = "grant_type=client_credentials%str(&)client_id=&CLIENT_ID%str(&)client_secret=&CLIENT_SECRET%str(&)scope=https://graph.microsoft.com/.default"
ct = "application/x-www-form-urlencoded"
out = tkn;
run;
libname jt json fileref=tkn;
data _null_;
set jt.root;
call symputx('access_token', access_token, 'L');
run;
libname jt clear;
filename tkn clear;
%if %length(&access_token) = 0 %then %do;
%put ERROR: Token acquisition failed;
%return;
%end;
/* ── Attachment metadata ──────────────────────────────────── */
%if &has_attach %then %do;
data _null_;
length fname $512 ext $20 ctype $100 aname $256;
fname = "&attachment";
aname = scan(fname, -1, '/\');
ext = lowcase(scan(aname, -1, '.'));
select (ext);
when ('csv') ctype = 'text/csv';
when ('txt') ctype = 'text/plain';
when ('htm','html') ctype = 'text/html';
when ('zip') ctype = 'application/zip';
when ('pdf') ctype = 'application/pdf';
when ('doc') ctype = 'application/msword';
when ('docx') ctype = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
when ('xls') ctype = 'application/vnd.ms-excel';
when ('xlsx') ctype = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
when ('ppt') ctype = 'application/vnd.ms-powerpoint';
when ('pptx') ctype = 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
otherwise ctype = 'application/octet-stream';
end;
call symputx('content_type', ctype, 'L');
call symputx('attachment_name', aname, 'L');
run;
%end;
/* ── Recipient counts ─────────────────────────────────────── */
%let n_to = %sysfunc(countw(&to, %str( )));
%let n_cc = %sysfunc(countw(&cc, %str( )));
/* ── Pre-resolve each address into its own macro variable ─── */
%do i = 1 %to &n_to;
%local to_&i;
%let to_&i = %scan(&to, &i, %str( ));
%end;
%if &has_cc %then %do;
%do j = 1 %to &n_cc;
%local cc_&j;
%let cc_&j = %scan(&cc, &j, %str( ));
%end;
%end;
filename mail_out temp recfm=n;
/* ── STEP 1: Write JSON header ────────────────────────────── */
data _null_;
file mail_out recfm=n mod;
length subject_esc $2000 body_chunk $32767 body_full $32767
attname $256 conttype $100;
subject_esc = symget('subject');
subject_esc = tranwrd(subject_esc, '\', '\\');
subject_esc = tranwrd(subject_esc, '"', '\"');
put '{"message":{"subject":"' subject_esc +(-1)
'","body":{"contentType":"HTML","content":"' @@;
body_full = symget('body');
total_len = length(body_full);
pos = 1;
do while (pos <= total_len);
seg_len = min(32000, total_len - pos + 1);
body_chunk = substr(body_full, pos, seg_len);
body_chunk = tranwrd(body_chunk, '\', '\\');
body_chunk = tranwrd(body_chunk, '"', '\"');
body_chunk = tranwrd(body_chunk, '0A'x, '\n');
body_chunk = tranwrd(body_chunk, '0D'x, '\r');
put body_chunk $varying32767. seg_len @@;
pos = pos + seg_len;
end;
put '"},"toRecipients":[' @@;
%do i = 1 %to &n_to;
%if &i > 1 %then put ',' @@; ;
put '{"emailAddress":{"address":"' "&&to_&i" '"}}' @@;
%end;
put ']' @@;
%if &has_cc %then %do;
put ',"ccRecipients":[' @@;
%do j = 1 %to &n_cc;
%if &j > 1 %then put ',' @@; ;
put '{"emailAddress":{"address":"' "&&cc_&j" '"}}' @@;
%end;
put ']' @@;
%end;
%if &has_attach %then %do;
attname = symget('attachment_name');
conttype = symget('content_type');
put ',"attachments":[{"@odata.type":"#microsoft.graph.fileAttachment","name":"'
attname +(-1) '","contentType":"' conttype +(-1)
'","contentBytes":"' @@;
%end;
%else %do;
put '},"saveToSentItems":"true"}' @@;
%end;
run;
/* ── STEP 2: Append base64 encoded attachment ─────────────── */
%if &has_attach %then %do;
/*
recfm=f lrecl=1 reads exactly one raw byte per iteration.
This is the most reliable binary read on SAS Viya Linux —
avoids fopen/fget text-mode corruption of binary content.
*/
filename att_src "&attachment" recfm=f lrecl=1;
filename b64_tmp temp recfm=n;
data _null_;
file b64_tmp recfm=n;
infile att_src recfm=f lrecl=1 end=eof;
length raw_byte $1 raw_buf $3000 b64_buf $4000;
retain raw_buf '' buf_len 0;
input raw_byte $char1.;
buf_len + 1;
substr(raw_buf, buf_len, 1) = raw_byte;
/* When buffer is full encode and flush */
if buf_len = 3000 then do;
b64_buf = putc(raw_buf, '$base64x4000');
put b64_buf $char4000. @@;
raw_buf = repeat('00'x, 2999);
buf_len = 0;
end;
/* On last byte flush remaining partial buffer */
if eof and buf_len > 0 then do;
b64_len = ceil(buf_len / 3) * 4;
b64_buf = putc(
substr(raw_buf, 1, buf_len),
cats('$base64x', b64_len)
);
put b64_buf $varying4000. b64_len @@;
end;
run;
filename att_src clear;
/* Copy b64 content into mail_out */
data _null_;
file mail_out recfm=n mod;
infile b64_tmp recfm=n end=eof;
length chunk $4000;
input chunk $char4000.;
len = length(chunk);
put chunk $varying4000. len @@;
run;
filename b64_tmp clear;
/* ── STEP 3: Append JSON footer ───────────────────────── */
data _null_;
file mail_out recfm=n mod;
put '"}]},"saveToSentItems":"true"}' @@;
run;
%end;
/* ── POST to Microsoft Graph ──────────────────────────────── */
filename resp temp;
proc http
url = "https://graph.microsoft.com/v1.0/users/&SENDER_EMAIL/sendMail"
method = "POST"
in = mail_out
out = resp;
headers
"Authorization" = "Bearer &access_token"
"Content-Type" = "application/json";
run;
%put STATUS: &=SYS_PROCHTTP_STATUS_CODE;
data _null_;
infile resp;
input;
putlog _infile_;
run;
/* ── Tidy up ──────────────────────────────────────────────── */
filename mail_out clear;
filename resp clear;
%mend send_mail;
/* ══════════════════════════════════════════════════════════════
EXAMPLE CALL
══════════════════════════════════════════════════════════════ */
data _null_;
dt = datetime();
call symputx(
'mail_body',
catx('',
'<html><body>',
'<p>Hello,</p>',
'<p>The scheduled job completed successfully on ',
put(datepart(dt), ddmmyy10.),
' at ',
put(timepart(dt), time8.),
'.</p>',
'<p>Thank you,<br>Support Team</p>',
'</body></html>'
),
'L'
);
run;
%send_mail(
to = [email protected],
subject = Test SAS Extract Completed Successfully,
body = %superq(mail_body),
attachment = /mnt/discovery/ukida/_shared/test14.zip
)
It's your turn to help shape SAS Innovate 2027. Share your expertise and inspire the SAS community.
Carleigh Jo Crabtree presents a simplified intro to SAS Viya for programmers.
Find more tutorials on the SAS Users YouTube channel.