@@ -20,7 +20,7 @@ import { google } from 'googleapis';
2020
2121// Mock the modules
2222jest . mock ( 'googleapis' ) ;
23- jest . mock ( 'fs/promises' ) ;
23+ jest . mock ( 'node: fs/promises' ) ;
2424jest . mock ( '../../utils/logger' ) ;
2525jest . mock ( '../../utils/MimeHelper' ) ;
2626
@@ -760,6 +760,25 @@ describe('GmailService', () => {
760760 expect ( response . labelIds ) . toEqual ( [ 'SENT' ] ) ;
761761 } ) ;
762762
763+ it ( 'should support replyTo in email' , async ( ) => {
764+ mockGmailAPI . users . messages . send . mockResolvedValue ( {
765+ data : { id : 'sent-msg-reply' } ,
766+ } ) ;
767+
768+ await gmailService . send ( {
769+ to : 'recipient@example.com' ,
770+ subject : 'Test Subject' ,
771+ body : 'Test Body' ,
772+ replyTo : 'support@example.com' ,
773+ } ) ;
774+
775+ expect ( MimeHelper . createMimeMessage ) . toHaveBeenCalledWith (
776+ expect . objectContaining ( {
777+ replyTo : 'support@example.com' ,
778+ } ) ,
779+ ) ;
780+ } ) ;
781+
763782 it ( 'should send email with multiple recipients' , async ( ) => {
764783 mockGmailAPI . users . messages . send . mockResolvedValue ( {
765784 data : { id : 'sent-msg-2' } ,
@@ -822,6 +841,13 @@ describe('GmailService', () => {
822841 ( MimeHelper . createMimeMessage as jest . Mock ) = jest
823842 . fn ( )
824843 . mockReturnValue ( 'base64encodedmessage' ) ;
844+ ( MimeHelper . createMimeMessageWithAttachments as jest . Mock ) = jest
845+ . fn ( )
846+ . mockReturnValue ( 'base64encodedmessage-with-attachments' ) ;
847+ ( fs . stat as any ) . mockResolvedValue ( {
848+ isFile : ( ) => true ,
849+ size : 1024 ,
850+ } ) ;
825851 } ) ;
826852
827853 it ( 'should create a draft email' , async ( ) => {
@@ -843,6 +869,18 @@ describe('GmailService', () => {
843869 body : 'Draft Body' ,
844870 } ) ;
845871
872+ expect ( MimeHelper . createMimeMessage ) . toHaveBeenCalledWith ( {
873+ to : 'recipient@example.com' ,
874+ subject : 'Draft Subject' ,
875+ body : 'Draft Body' ,
876+ cc : undefined ,
877+ bcc : undefined ,
878+ replyTo : undefined ,
879+ isHtml : false ,
880+ inReplyTo : undefined ,
881+ references : undefined ,
882+ } ) ;
883+
846884 expect ( mockGmailAPI . users . drafts . create ) . toHaveBeenCalledWith ( {
847885 userId : 'me' ,
848886 requestBody : {
@@ -859,6 +897,60 @@ describe('GmailService', () => {
859897 expect ( response . message . threadId ) . toBe ( 'thread1' ) ;
860898 } ) ;
861899
900+ it ( 'should support replyTo in draft email' , async ( ) => {
901+ mockGmailAPI . users . drafts . create . mockResolvedValue ( {
902+ data : { id : 'd-reply' , message : { id : 'm-reply' } } ,
903+ } ) ;
904+
905+ await gmailService . createDraft ( {
906+ to : 'recipient@example.com' ,
907+ subject : 'Draft Subject' ,
908+ body : 'Draft Body' ,
909+ replyTo : 'support@example.com' ,
910+ } ) ;
911+
912+ expect ( MimeHelper . createMimeMessage ) . toHaveBeenCalledWith (
913+ expect . objectContaining ( {
914+ replyTo : 'support@example.com' ,
915+ } ) ,
916+ ) ;
917+ } ) ;
918+
919+ it ( 'should enforce maximum total attachment size' , async ( ) => {
920+ ( fs . stat as any ) . mockResolvedValue ( {
921+ isFile : ( ) => true ,
922+ size : 30 * 1024 * 1024 , // 30MB
923+ } ) ;
924+
925+ const result = await gmailService . createDraft ( {
926+ to : 'recipient@example.com' ,
927+ subject : 'Too Large' ,
928+ body : 'Body' ,
929+ attachments : [ { filePath : '/tmp/huge.zip' } ] ,
930+ } ) ;
931+
932+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
933+ expect ( response . error ) . toContain ( 'exceeds the maximum allowed limit' ) ;
934+ expect ( fs . readFile ) . not . toHaveBeenCalled ( ) ;
935+ } ) ;
936+
937+ it ( 'should validate attachment path is a file' , async ( ) => {
938+ ( fs . stat as any ) . mockResolvedValue ( {
939+ isFile : ( ) => false ,
940+ size : 0 ,
941+ } ) ;
942+
943+ const result = await gmailService . createDraft ( {
944+ to : 'recipient@example.com' ,
945+ subject : 'Not a file' ,
946+ body : 'Body' ,
947+ attachments : [ { filePath : '/tmp/directory' } ] ,
948+ } ) ;
949+
950+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
951+ expect ( response . error ) . toContain ( 'path is not a file' ) ;
952+ } ) ;
953+
862954 it ( 'should handle draft creation errors' , async ( ) => {
863955 const apiError = new Error ( 'Failed to create draft' ) ;
864956 mockGmailAPI . users . drafts . create . mockRejectedValue ( apiError ) ;
@@ -994,6 +1086,228 @@ describe('GmailService', () => {
9941086 const response = JSON . parse ( result . content [ 0 ] . text ) ;
9951087 expect ( response . status ) . toBe ( 'draft_created' ) ;
9961088 } ) ;
1089+
1090+ it ( 'should create a draft with attachments using createMimeMessageWithAttachments' , async ( ) => {
1091+ const mockDraft = {
1092+ id : 'draft-attach-1' ,
1093+ message : { id : 'msg-attach-1' , threadId : null , labelIds : [ 'DRAFT' ] } ,
1094+ } ;
1095+ mockGmailAPI . users . drafts . create . mockResolvedValue ( { data : mockDraft } ) ;
1096+
1097+ const mockFileBuffer = Buffer . from ( 'PDF content' ) ;
1098+ ( fs . readFile as any ) . mockResolvedValue ( mockFileBuffer ) ;
1099+
1100+ const result = await gmailService . createDraft ( {
1101+ to : 'recipient@example.com' ,
1102+ subject : 'Draft with Attachment' ,
1103+ body : 'See attached.' ,
1104+ attachments : [ { filePath : '/tmp/report.pdf' , mimeType : 'application/pdf' } ] ,
1105+ } ) ;
1106+
1107+ expect ( ( fs . readFile as any ) . mock . calls [ 0 ] [ 0 ] ) . toBe ( '/tmp/report.pdf' ) ;
1108+ expect (
1109+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1110+ ) . toHaveBeenCalledWith (
1111+ expect . objectContaining ( {
1112+ attachments : [
1113+ {
1114+ filename : 'report.pdf' ,
1115+ content : mockFileBuffer ,
1116+ contentType : 'application/pdf' ,
1117+ } ,
1118+ ] ,
1119+ inReplyTo : undefined ,
1120+ references : undefined ,
1121+ } ) ,
1122+ ) ;
1123+ expect ( MimeHelper . createMimeMessage ) . not . toHaveBeenCalled ( ) ;
1124+
1125+ expect ( mockGmailAPI . users . drafts . create ) . toHaveBeenCalledWith ( {
1126+ userId : 'me' ,
1127+ requestBody : { message : { raw : 'base64encodedmessage-with-attachments' } } ,
1128+ } ) ;
1129+
1130+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
1131+ expect ( response . status ) . toBe ( 'draft_created' ) ;
1132+ expect ( response . id ) . toBe ( 'draft-attach-1' ) ;
1133+ } ) ;
1134+
1135+ it ( 'should use filename override when provided' , async ( ) => {
1136+ mockGmailAPI . users . drafts . create . mockResolvedValue ( {
1137+ data : { id : 'draft2' , message : { id : 'msg2' , threadId : null , labelIds : [ ] } } ,
1138+ } ) ;
1139+ ( fs . readFile as any ) . mockResolvedValue ( Buffer . from ( 'data' ) ) ;
1140+
1141+ await gmailService . createDraft ( {
1142+ to : 'a@example.com' ,
1143+ subject : 'S' ,
1144+ body : 'B' ,
1145+ attachments : [ { filePath : '/tmp/123abc.tmp' , filename : 'custom-name.pdf' } ] ,
1146+ } ) ;
1147+
1148+ expect (
1149+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1150+ ) . toHaveBeenCalledWith (
1151+ expect . objectContaining ( {
1152+ attachments : expect . arrayContaining ( [
1153+ expect . objectContaining ( { filename : 'custom-name.pdf' } ) ,
1154+ ] ) ,
1155+ } ) ,
1156+ ) ;
1157+ } ) ;
1158+
1159+ it ( 'should infer MIME type from extension when mimeType not provided' , async ( ) => {
1160+ mockGmailAPI . users . drafts . create . mockResolvedValue ( {
1161+ data : { id : 'd3' , message : { id : 'm3' , threadId : null , labelIds : [ ] } } ,
1162+ } ) ;
1163+ ( fs . readFile as any ) . mockResolvedValue ( Buffer . from ( 'data' ) ) ;
1164+
1165+ await gmailService . createDraft ( {
1166+ to : 'a@example.com' ,
1167+ subject : 'S' ,
1168+ body : 'B' ,
1169+ attachments : [ { filePath : '/tmp/report.xlsx' } ] ,
1170+ } ) ;
1171+
1172+ expect (
1173+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1174+ ) . toHaveBeenCalledWith (
1175+ expect . objectContaining ( {
1176+ attachments : expect . arrayContaining ( [
1177+ expect . objectContaining ( {
1178+ contentType :
1179+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ,
1180+ } ) ,
1181+ ] ) ,
1182+ } ) ,
1183+ ) ;
1184+ } ) ;
1185+
1186+ it ( 'should fall back to application/octet-stream for unknown extension' , async ( ) => {
1187+ mockGmailAPI . users . drafts . create . mockResolvedValue ( {
1188+ data : { id : 'd4' , message : { id : 'm4' , threadId : null , labelIds : [ ] } } ,
1189+ } ) ;
1190+ ( fs . readFile as any ) . mockResolvedValue ( Buffer . from ( 'data' ) ) ;
1191+
1192+ await gmailService . createDraft ( {
1193+ to : 'a@example.com' ,
1194+ subject : 'S' ,
1195+ body : 'B' ,
1196+ attachments : [ { filePath : '/tmp/mystery.xyz' } ] ,
1197+ } ) ;
1198+
1199+ expect (
1200+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1201+ ) . toHaveBeenCalledWith (
1202+ expect . objectContaining ( {
1203+ attachments : expect . arrayContaining ( [
1204+ expect . objectContaining ( { contentType : 'application/octet-stream' } ) ,
1205+ ] ) ,
1206+ } ) ,
1207+ ) ;
1208+ } ) ;
1209+
1210+ it ( 'should pass inReplyTo and references to createMimeMessageWithAttachments for threaded draft with attachments' , async ( ) => {
1211+ const mockDraft = {
1212+ id : 'draft-thread-attach' ,
1213+ message : { id : 'msg-ta' , threadId : 'thread1' , labelIds : [ 'DRAFT' ] } ,
1214+ } ;
1215+ mockGmailAPI . users . threads . get . mockResolvedValue ( {
1216+ data : {
1217+ messages : [
1218+ {
1219+ payload : {
1220+ headers : [
1221+ { name : 'Message-ID' , value : '<orig@mail.example.com>' } ,
1222+ { name : 'References' , value : '<prev@mail.example.com>' } ,
1223+ ] ,
1224+ } ,
1225+ } ,
1226+ ] ,
1227+ } ,
1228+ } ) ;
1229+ mockGmailAPI . users . drafts . create . mockResolvedValue ( { data : mockDraft } ) ;
1230+ ( fs . readFile as any ) . mockResolvedValue ( Buffer . from ( 'data' ) ) ;
1231+
1232+ const result = await gmailService . createDraft ( {
1233+ to : 'b@example.com' ,
1234+ subject : 'Re: Attached Reply' ,
1235+ body : 'See file.' ,
1236+ threadId : 'thread1' ,
1237+ attachments : [ { filePath : '/tmp/file.pdf' } ] ,
1238+ } ) ;
1239+
1240+ expect (
1241+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1242+ ) . toHaveBeenCalledWith (
1243+ expect . objectContaining ( {
1244+ inReplyTo : '<orig@mail.example.com>' ,
1245+ references : '<prev@mail.example.com> <orig@mail.example.com>' ,
1246+ } ) ,
1247+ ) ;
1248+ expect ( mockGmailAPI . users . drafts . create ) . toHaveBeenCalledWith ( {
1249+ userId : 'me' ,
1250+ requestBody : {
1251+ message : {
1252+ raw : 'base64encodedmessage-with-attachments' ,
1253+ threadId : 'thread1' ,
1254+ } ,
1255+ } ,
1256+ } ) ;
1257+
1258+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
1259+ expect ( response . status ) . toBe ( 'draft_created' ) ;
1260+ } ) ;
1261+
1262+ it ( 'should reject a relative filePath and return error without calling Gmail API' , async ( ) => {
1263+ const result = await gmailService . createDraft ( {
1264+ to : 'a@example.com' ,
1265+ subject : 'S' ,
1266+ body : 'B' ,
1267+ attachments : [ { filePath : 'relative/path/file.pdf' } ] ,
1268+ } ) ;
1269+
1270+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
1271+ expect ( response . error ) . toContain ( 'must be an absolute path' ) ;
1272+ expect ( mockGmailAPI . users . drafts . create ) . not . toHaveBeenCalled ( ) ;
1273+ expect ( ( fs . readFile as any ) . mock . calls ) . toHaveLength ( 0 ) ;
1274+ } ) ;
1275+
1276+ it ( 'should handle readFile failure (file not found) gracefully' , async ( ) => {
1277+ ( fs . readFile as any ) . mockRejectedValue (
1278+ new Error ( 'ENOENT: no such file' ) ,
1279+ ) ;
1280+
1281+ const result = await gmailService . createDraft ( {
1282+ to : 'a@example.com' ,
1283+ subject : 'S' ,
1284+ body : 'B' ,
1285+ attachments : [ { filePath : '/tmp/missing.pdf' } ] ,
1286+ } ) ;
1287+
1288+ const response = JSON . parse ( result . content [ 0 ] . text ) ;
1289+ expect ( response . error ) . toContain ( 'ENOENT' ) ;
1290+ expect ( mockGmailAPI . users . drafts . create ) . not . toHaveBeenCalled ( ) ;
1291+ } ) ;
1292+
1293+ it ( 'should use createMimeMessage (not WithAttachments) when attachments array is empty' , async ( ) => {
1294+ mockGmailAPI . users . drafts . create . mockResolvedValue ( {
1295+ data : { id : 'd5' , message : { id : 'm5' , threadId : null , labelIds : [ ] } } ,
1296+ } ) ;
1297+
1298+ await gmailService . createDraft ( {
1299+ to : 'a@example.com' ,
1300+ subject : 'S' ,
1301+ body : 'B' ,
1302+ attachments : [ ] ,
1303+ } ) ;
1304+
1305+ expect ( MimeHelper . createMimeMessage ) . toHaveBeenCalled ( ) ;
1306+ expect (
1307+ MimeHelper . createMimeMessageWithAttachments as jest . Mock ,
1308+ ) . not . toHaveBeenCalled ( ) ;
1309+ expect ( ( fs . readFile as any ) . mock . calls ) . toHaveLength ( 0 ) ;
1310+ } ) ;
9971311 } ) ;
9981312
9991313 describe ( 'sendDraft' , ( ) => {
0 commit comments